A Piano for SuperCollider

Complete code and documentation.
February 26, 2020

Last winter, I started to work on an acoustic piano multi-sampler for SuperCollider. The earliest versions had many issues, and my budding programming skills in sclang did not allow me to fix them. But I’ve used the instrument a lot since then, and I’ve improved the code along the way. I plan to keep working on the instrument as I keep using it, but I’m thinking that it could already be useful for other people if they are interested. So here it is.

A very simple demonstration of the sampler using two patterns created with Pbind.
0:00
9:00

The instrument uses a public domain sample pack I used this same sample pack for the soundtrack of my short film Étude for Cellular Automata no 2, although in this case I used a single piano sample and shifted its pitch down over several octaves to generate every note. It creates a very warbly sound that was appropriate for the intended atmosphere. I also used the piano samples in this live coding experiment.that you can download from freesound.org. You’ll need to create an account to download it, but it’s free to join, and free to download, and I highly recommend this website.

Below is the entirety of the code for the sampler, along with some Pbind examples to try it out. It is free software distributed under an Apache 2.0 licence. You can also find this code on GitHub.

// Run this block of code once the server is booted.
// You also need to make sure that the packLocation variable
// is set to the actual location of the downloaded sample pack.

(
var pianoSamples, pianoFolder, makeLookUp, indices, pitches, dynAmnt, maxDyn, maxNote,
packLocation = "/21055__samulis__vsco-2-ce-keys-upright-piano/",
quiet = false;

dynAmnt = if (quiet, {2}, {3});
maxDyn = if (quiet, {1}, {2});
maxNote = if (quiet, {46}, {1e2});
pianoSamples = Array.new;
pianoFolder = PathName.new(packLocation);
pianoFolder.entries.do({
    |path, i|
    if (i < maxNote, {
        pianoSamples = pianoSamples.add(Buffer.read(s, path.fullPath));
    });
});

makeLookUp = {
    |note, dynamic|
    var octave = floor(note / 12) - 2;
    var degree = note % 12;
    var sampledNote = [1,  1,  1,  1,  2,  2,  2,  3,  3,  3,  3,  3];
    var noteDeltas = [-1, 0,  1,  2, -1,  0,  1, -2, -1,  0,  1,  2];
    var dynamicOffset = dynamic * 23;
    var sampleToGet = octave * 3 + sampledNote[degree] + dynamicOffset;
    var pitch = noteDeltas[degree];
    [sampleToGet, pitch];
};
indices = dynAmnt.collect({|j| (20..110).collect({|i| makeLookUp.(i, j)[0]})}).flat;
pitches = dynAmnt.collect({|j| (20..110).collect({|i| makeLookUp.(i, j)[1]})}).flat;

Event.addEventType(\pianoEvent, {
    var index;
    if (~num.isNil, {~num = 60}, {~num = min(max(20, ~num), 110)});
    if (~dyn.isNil, {~dyn = 0}, {~dyn = floor(min(max(0, ~dyn), maxDyn))});
    index = floor(~num) - 20 + (~dyn * 91);
    ~buf = pianoSamples[indices[index]];
    ~rate = (pitches[index] + frac(~num)).midiratio;
    ~instrument = \pianoSynth;
    ~type = \note;
    currentEnvironment.play;
});

SynthDef(\pianoSynth, {
    arg buf = pianoSamples[0], rate = 1, spos = 0, pan = 0, amp = 1, out = 0, atk = 0, sus = 0, rel = 8;
    var sig, env;
    env = EnvGen.kr(Env.new([0, 1, 1, 0], [atk, sus, rel]), doneAction: 2);
    sig = PlayBuf.ar(2, buf, rate * BufRateScale.ir(buf), startPos: spos, doneAction: 2);
    sig = sig * amp * 18 * env;
    sig = Balance2.ar(sig[0], sig[1], pan, 1);
    Out.ar(out, sig);
}).add;
)

// Below are examples of patterns that show how to use the instrument.
// I recommend running both patterns at the same time,
// they are made to complement each other.

(
var key = 62;
var notes = key + ([0, 3, 7, 10] ++ [-5, 2, 3, 9]);
~pianoRiff.stop;
~pianoRiff = Pbind(
    \type, \pianoEvent,
    \dur, Pseq(0.5!1 ++ (0.25!3), inf),
    \num, Pseq(notes, inf),
    \dyn, Pseq([1, 0, 0, 1], inf),
    \amp, Pseq([0.5, 2, 2, 0.5], inf),
    \pan, Pwhite(-0.75, 0.75, inf),
    \rel, 4
).play(quant: [2]);
)

(
var key = 62 + 36;
var notes = key + [2, -5, 0, -2];
~pianoRiff2.stop;
~pianoRiff2 = Pbind(
    \type, \pianoEvent,
    \dur, Pseq([0.25, 1.75], inf),
    \num, Pseq(notes, inf),
    \dyn, Pseq([1, 1, 1, 1], inf),
    \amp, Pseq([0.5, 1, 1, 0.5], inf),
    \pan, Pwhite(-0.75, 0.75, inf),
    \rel, 4
).play(quant: [2]);
)

(
~pianoRiff.stop;
~pianoRiff2.stop;
)

How to use the sampler

Using the instrument will probably be very straightforward for most users of SuperCollider, but I thought that writing down some instructions could still be useful, particularly for beginners. It’s also an opportunity to talk about the design decisions that went into creating the instrument, and the ways in which it could potentially be improved.

My main goal for this project was to use the piano with instances of Pbind that would be as uncluttered as possible. Here is a Pbind that uses all features of the instrument:

(
var key = 62;
var notes = key + ([0, 3, 7, 10] ++ [-5, 2, 3, 9]);
~pianoRiff.stop;
~pianoRiff = Pbind(
    \type, \pianoEvent,
    \dur, Pseq(0.5!1 ++ (0.25!3), inf),
    \num, Pseq(notes, inf),
    \dyn, Pseq([1, 0, 0, 1], inf),
    \amp, Pseq([0.5, 2, 2, 0.5], inf),
    \pan, Pwhite(-0.75, 0.75, inf),
    \rel, 4
).play(quant: [2]);
)

The instrument is played by using a custom event type named \pianoEvent. This event type computes all the logic behind the instrument and leaves all of that out of the Pbind. The notes that you want to play must be defined with the \num key. I wanted to use the \note key but Pbind seems to have some internal functionality for this key. The notes can be between 20 and 110. These are standard midi note values, 20 being a G#-1 and 110 being a D8. There are “safety measures” in the instrument, so if you ask for notes below 20 or above 110 it’ll give you the closest available note (so 20 or 110). Negative notes will return 20. Notably, if you ask for a non-integer note like 50.5, it will give you a note between 50 and 51. Any non-integer values work, which makes this instrument usable for microtonal music.

Each note in the sample pack exists in three different levels of loudness (or dynamics) from quiet, to medium, to loud. The dynamics of a note is defined by the \dyn key in the Pbind. \dyn can either be 0, 1, or 2. The safety measures will floor this \dyn value, so a loudness of 0.9 will return 0. A loudness above 2 will return 2, and negative values will return 0. A Pbind that does not include a \dyn key will default to a value of 0.

The \amp key in the Pbind simply scales the amplitude of the note. It has no effect on which level of dynamics is played. The instrument also has optional atk, sus, and rel arguments, for attack, sustain, and release. These are useful mainly because the noise floor in the samples is quite high, so you may want to limit the duration over which a note is played, to reduce the noise.

The quiet mode

You can see near the top of the code a line that goes: quiet = false. If you change this to true before you evaluate the code, it will prevent the loud samples from being loaded. I wrote this option because I personally never use the loud samples. I tend to write quiet music and I don’t have much use for loud sounds, so loading them is a waste of memory (perhaps a very small waste, but a waste nonetheless). In this quiet mode, a \dyn value above 1 in the Pbind will return 1, because the samples with a dynamic of 2 will not be loaded.

How it all works

The sample pack contains three samples per octave: C#, F, and A. The basic modus operandi of the sampler is to take a note that you want to play and look for the closest note that it can find in the pack. And then, if the closest note is not the exact one that you asked for, the sampler has to play this closest note at a different pitch to produce the desired note. So if you want a C, it will play the recorded C# note at a pitch ratio of (21/12)1 because the desired note is 1 note away from the closest note. This bit of mathematics is how the twelve-tone equal temperament is built. Any distance n between the desired note and the available note will similarly result in the available note being played at a ratio of (21/12)n to obtain the desired note.

In the code box below you can see how each octave is built, starting on the note C. For each degree of the octave, we define which sample will be used in the sampledNote variable: C# is 1, F is 2, and A is 3. In the noteDeltas variable, we define the distance (or interval) between the note that we want, and the closest available note. So it follows that for C#, F, and A, the value in noteDeltas is 0. And since (21/12)0=1, these notes will be played at a pitch ratio of 1.

var sampledNote = [1,  1,  1,  1,  2,  2,  2,  3,  3,  3,  3,  3];
var noteDeltas = [-1, 0,  1,  2, -1,  0,  1, -2, -1,  0,  1,  2];

These two lines of code are found in the makeLookUp() function, which is used to create two lookup tables that will then be used when the instrument is played. The purpose of a lookup table is to prepare data that will be used frequently instead of running an algorithm that generates the same data every time. In this case, the calculations are really simple and fast, but I thought that lookup tables were still an interesting way to organize the program and make it theoretically even faster (but mostly I just wanted to experiment with lookup tables). The lines below is where we run this makeLookUp() function and build the two lookup tables, one for the indices of the available closest buffers and one for the pitch ratios at which the notes will be played. You can see two nested loops here: one loop over the dynAmnt value, which is the amount of different dynamics that will be available, and the other loop over an Array that goes from 20 to 110, which is a list of all available notes.

indices = dynAmnt.collect({|j| (20..110).collect({|i| makeLookUp.(i, j)[0]})}).flat;
pitches = dynAmnt.collect({|j| (20..110).collect({|i| makeLookUp.(i, j)[1]})}).flat;

The \pianoEvent event type is where the lookup tables are used. It’s also where the numbers defined in a Pbind are sanitized (you can see the ~num and ~dyn variables being constrained with min() and max() operators), It’s confusing but the keys that were defined in the Pbind, like \num and \dyn, they are referred to as ~num and ~dyn within the Event that the Pbind activates. The ~ before the variable names means that they are environment variables, the environment being the Event itself. and where the microtonality is made possible.

Event.addEventType(\pianoEvent, {
    var index;
    if (~num.isNil, {~num = 60}, {~num = min(max(20, ~num), 110)});
    if (~dyn.isNil, {~dyn = 0}, {~dyn = floor(min(max(0, ~dyn), maxDyn))});
    index = floor(~num) - 20 + (~dyn * 91);
    ~buf = pianoSamples[indices[index]];
    ~rate = (pitches[index] + frac(~num)).midiratio;
    ~instrument = \pianoSynth;
    ~type = \note;
    currentEnvironment.play;
});

The microtonality is calculated on the line where the variable ~rate is defined. By taking the fractional value of ~num and by adding it to the “note delta” that was previously computed in the creation of the lookup table, we obtain the final distance between the desired note and the available note, and we pass this distance to the midiratio operator, which is doing the same calculation as above: (21/12)n where n is the input. This fractional value is only present when the notes you ask for are not integers—when you are playing with microtonality.

Different ways of handling dynamics

One of the ways I can think of improving this instrument is creating a better system to handle dynamics. The current system of manually selecting which of the three samples (quiet, medium, or loud) is used, is somewhat clunky. What happens if you want to gradually increase the dynamics of a pattern, or if you just want a note between quiet and medium? It’s definitely possible, but you have to carefully think about which sample will be played at which amplitude, and create all the necessary lists to express your idea. Here is one example of a pattern that gets repeated at gradually increasing dynamics:

(
var n = 20;
var r = -1 / n;
var decrease = Pseries(1, r, n).asStream.nextN(n);
var increase = decrease.reverse * 0.75;
var amplitudes = [decrease, increase].lace(n * 2);
~crescendoRiff.stop;
~crescendoRiff = Pbind(
    \type, \pianoEvent,
    \dur, Pseq([1, 1, 2, 3, 1].stutter(2) * 0.25 * 0.35, inf)
    * Pseq([0, 1], inf),
    \num, 62 
    + Pseq(([0, 7, 4, 11, 9] ++ [-3, 4, 2, 9, 4]).stutter(2), inf)
    + Pseq([0, -5].stutter(n * 4), inf),
    \dyn, Pseq([0, 1], inf) + Pseq([0, 1].stutter(n * 2), inf),
    \amp, Pseq(amplitudes ++ (amplitudes * 0.75), inf) * 0.5,
    \rel, 2
).play();
)
A pattern with gradually increasing dynamics.
0:00
9:00

We start by creating two lists, one of decreasing values (for the amplitudes of the quiet samples) and one of increasing values (for the amplitudes of the medium samples). We then lace these two lists, creating a single list that will interlace the two streams of quiet and medium notes. And then, in the \dur key of the Pbind, we multiply our list of durations by Pseq([0, 1], inf), which means that the first note of every pair will have a duration of 0, which means that two notes will always play simultaneously for each duration set by the first Pseq within \dur. We then list the notes that we want to play in \num, making sure to stutter() the list by 2 because each note will be played simultaneously twice.

And then, in \dyn, we create a repeating stream of [0, 1], 0 being the quiet sample and 1 being the medium sample. After n * 2 times, so after the full gradient of increases and decreases is complete, we add 1 to this pattern with the second Pseq. So we end up with a repeating stream of [1, 2], because now we interpolate between the medium sample and the loud sample.

This way of creating a smooth gradient of dynamics is laborious, but there are some reasons for which it isn’t necessarily a bad system. The ideal system would simply take a floating point between 0 and 1 for the \dyn key, and automatically play two samples at varying amplitudes, creating the interpolated dynamics. 0 would be the quietest and 1 the loudest. But this ideal system is difficult to build without creating unintended problems. First of all, playing two samples simultaneously, particularly when you let them ring for a long time, can create subtle phasing effects. These may be acceptable or not depending on the context. And also, there are some irregularities in the sample pack, certain notes were recorded at slightly different dynamics or slightly different amplitudes. A careful list of “exceptions” could be created to manually change the amplitude of certain samples and bring them all to a more unified level, but this is beginning to feel like the work that I would do if I end up using this instrument with this specific sample pack for years and years. So when I create patterns, I enjoy the possibility of manually defining which sample will play at which amplitude. It’s the more complicated way of doing things, but also the way that offers the most control.

So I decided not to create a system of floating point dynamics for now. Maybe I’ll do it at some point.

Final thoughts

I really enjoyed putting this project together and it made me appreciate the strange and often confusing beauty of sclang even more. If you end up using the sampler, I’d love to have feedback on it or hear what you make with it. Don’t hesitate to contact me with comments or questions. Thanks for reading!