Un piano pour SuperCollider

Code complet du projet et documentation.
26 février 2020

L’hiver dernier, j’ai commencé à construire un échantillonneur de piano acoustique dans SuperCollider. Les premières versions du projet avaient plusieurs problèmes de fonctionnement et ma connaissance limitée de sclang me rendait la tâche assez ardue. J’ai depuis beaucoup joué avec l’échantillonneur et j’en ai amélioré le code au fil du temps. Je compte encore y travailler sporadiquement mais je me suis dit qu’il était déjà dans un état présentable et qu’il pourrait peut-être servir aux intéressé·e·s. Alors le voici.

Une très simple démonstration de l’échantillonneur avec deux motifs créés avec Pbind.
0:00
9:00

L’instrument utilise une collection d’échantillons J’avais utilisé ces mêmes échantillons pour la trame sonore de mon court métrage Étude pour automates cellulaires no. 2. J’avais composé ce morceau avec Ableton Live parce que je ne connaissais pas SuperCollider à ce moment-là. Le morceau n’utilise d’ailleurs qu’un ou deux échantillons différents parce que j’avais constaté que la construction d’un bon échantillonneur avec Ableton Live était grosso modo un cauchemar. J’ai plus tard réutilisé ces échantillons de piano dans cette expérience de programmation en direct qui est un de mes premiers projets réalisés avec SuperCollider.qui est dans le domaine public et que vous pouvez télécharger sur freesound.org. Il vous faudra vous créer un compte pour faire le téléchargement, mais la création d’un compte ainsi que les téléchargements sont gratuits. Il s’agit d’un excellent site Web que je recommande fortement.

Vous trouverez ci-dessous la totalité du code de l’échantillonneur ainsi que deux exemples de Pbind servant à faire une courte démonstration de l’instrument. Il s’agit d’un logiciel libre distribué avec une licence Apache 2.0. Vous pouvez aussi consulter ce code sur 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;
)

Utilisation de l’échantillonneur

La plupart des utilisateurs de SuperCollider pourront probablement faire usage de l’échantillonneur sans difficulté, mais j’ai pensé qu’il pourrait tout de même être utile de rédiger quelques instructions, particulièrement pour les débutants à qui les projets bien documentés peuvent beaucoup servir. Il s’agit aussi d’une opportunité de parler des multiples décisions prises lors de la création de l’instrument, et des divers moyens par lesquels il pourrait encore être amélioré.

Mon but premier avec ce projet était de jouer du piano avec des séquences générées avec Pbind, et de pouvoir écrire ces séquences avec une syntaxe qui ne soit pas obstruée par le mécanisme interne de l’instrument. Voici un Pbind qui utilise toutes les fonctionnalités de l’échantillonneur :

(
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]);
)

L’instrument se joue à l’aide d’un type d’évènement nommé \pianoEvent. Cet évènement gère toute les opérations logiques nécessaires à l’instrument et garde tout cela hors du Pbind. Les notes que vous voulez jouer doivent être définies avec la valeur \num. Je voulais utiliser la valeur \note, pour que la nomenclature soit plus claire, mais Pbind semble avoir une fonctionnalité interne particulière pour cette valeur. Les notes peuvent aller de 20 à 110. Il s’agit de valeurs midi standards, 20 étant un sol#-1 et 110 étant un ré8. L’instrument possède aussi quelques “mesures de sécurité”, ainsi si vous demandez une note inférieure à 20 ou supérieure à 110, la note disponible la plus près sera plutôt jouée (donc 20 ou 110). Les notes négatives seront transformées en la note 20. Détail intéressant, si vous demandez une note qui n’est pas un nombre entier, tel que 50.5, l’instrument jouera une note entre 50 et 51. Toute valeur fractionnelle fonctionne, ce qui permet d’utiliser cet instrument pour faire de la musique microtonale.

Chaque note dans la collection d’échantillons existe en trois différents niveaux de dynamique : doux, moyen et fort. La dynamique d’une note est définie par la valeur \dyn dans le Pbind. \dyn peut être 0, 1 ou 2. Les mesures de sécurité arrondissent la valeur \dyn au nombre entier le plus bas, ainsi une valeur de 0.9 devient 0. Une valeur supérieure à 2 devient 2, et une valeur négative devient 0. Un Pbind qui n’inclut aucune définition pour la valeur \dyn jouera des notes avec une dynamique de 0.

La valeur \amp dans le Pbind est simplement multipliée à l’amplitude des notes jouées. Elle n’a aucun effet sur la dynamique des notes. L’instrument a aussi d’autres valeurs optionnelles : atk, sus et rel, pour attack, sustain et release. Ces valeurs peuvent être utiles surtout parce que les échantillons ont une quantité non négligeable de bruit de fond, alors il peut être intéressant de diminuer la durée de jeu des notes afin de diminuer la quantité de bruit de fond qui s’accumule rapidement lorsque beaucoup de notes sont jouées simultanément.

Le mode quiet

Dans les premières lignes du code de l’instrument, on peut lire ceci : quiet = false. Si vous changez cette valeur pour true avant d’évaluer le code, les échantillons les plus forts ne seront pas mis en mémoire et ne seront donc pas disponibles. J’ai écrit cette option parce que je n’utilise personnellement jamais ces échantillons. J’écris surtout de la musique assez calme et je ne me sers jamais de sons très lourds ou intenses, alors charger ces échantillons serait pour moi un gaspillage de mémoire (un gaspillage assez petit, mais un gaspillage tout de même). Dans ce mode quiet, une valeur \dyn au-dessus de 1 dans un Pbind sera transformée en 1, parce que les échantillons avec une dynamique de 2 ne seront pas disponibles.

Fonctionnement du programme

La collection d’échantillons contient trois notes enregistrées pour chaque octave : do#, fa et la. Le modus operandi du programme consiste à prendre la note qu’on lui demande de jouer et à trouver l’enregistrement qui en est le plus près. Ensuite, si l’enregistrement trouvé par le programme n’est pas la note exacte dont l’utilisateur a fait la demande, le programme devra jouer cet enregistrement à une vitesse différente afin de produire la note voulue. Par exemple, si vous demandez un do, le programme jouera l’enregistremnt du do# à une vitesse de (21/12)1 puisque la note voulue est à une distance de 1 de la note enregistrée. Ce bout de mathématique provient de la construction de la gamme tempérée. Similairement, toute distance n entre la note voulue et la note enregistrée qui en est le plus près résultera en un jeu de la note enregistrée à une vitesse de (21/12)n.

Dans le bout de code ci-dessous, on peut voir comment chaque octave est construit, en partant de la note do. Dans la variable sampledNote, nous définissons quel échantillon sera utilisé pour chaque degrée de l’octave : do# est représenté par le nombre 1, fa par le nombre 2 et la par le nombre 3. Dans la variable noteDeltas, nous définissions la distance (ou l’intervalle) entre la note voulue et la note enregistrée qui est en le plus près. Il est donc logique d’observer que pour les notes do#, fa et la, la valeur associée dans noteDeltas est 0. Et puisque (21/12)0=1, ces notes seront jouées à une vitesse de 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];

Ces deux lignes de code se trouvent dans la fonction makeLookUp(), qui est utilisée pour créer deux tables de correspondance dont l’instrument se servira. Une table de correspondance sert à préparer de l’information qui sera fréquemment utilisée, ce qui est plus optimal qu’utiliser à répétition un algorithme qui renvoit à chaque fois la même information. Dans le cas présent, les calculs que l’instrument doit faire pour chaque note sont très simples et rapides, mais j’ai tout de même trouvé qu’il était intéressant de structurer le programme avec des tables de correspondance, et de le rendre théoriquement plus optimisé, même si le gain de performance n’est vraiment pas observable (mais bon, ce que je voulais c’était surtout apprendre à faire des tables de correspondance et m’amuser avec ça). Dans les lignes ci-dessous, cette fonction makeLookUp() est finalement utilisée pour créer deux tables de correspondance : la première qui associe chaque note à l’échantillon enregistré qui sera utilisé, et la seconde qui associe chaque note à la vitesse à laquelle cet échantillon sera joué. Vous pouvez voir deux boucles imbriquées : la boucle extérieure qui se fait avec la valeur dynAmnt, qui représente la quantité de dynamiques disponibles, et la boucle intérieure qui se fait avec une liste (Array) qui va de 20 jusqu’à 110, ce qui représente toutes les notes jouables.

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;

C’est dans l’évènement \pianoEvent que les tables de correspondance sont ensuite utilisées. C’est aussi là que les valeurs définies dans un Pbind sont testées et remplacées au besoin par des valeurs différentes (l’étendue des variables ~num et ~dyn est limitée par les opérateurs min() et max()). C’est assez mêlant tout ça, mais les valeurs qui sont définies dans un Pbind, comme \num and \dyn, elles deviennent les variables ~num et ~dyn à l’intérieur de l’évènement que le Pbind active. Le tilde (~) qui précède le nom de ces variables signifie qu’elles sont des “variables d’environnement” (environment variables), cet environnement étant l’évènement \pianoEvent.

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;
});

La microtonalité est aussi calculée dans \pianoEvent, à la ligne où est définie la valeur ~rate. En calculant la valeur fractionnelle de la valeur ~num (avec l’opérateur frac()), et en l’additionnant à la distance entre la note voulue et la note enregistrée (distance calculée précédemment lors de la création des tables de correspondance), on obtient la distance finale exacte entre la note voulue et la note disponible. On envoie ensuite cette distance dans l’opérateur midiratio, qui fait le même calcul mentionné plus haut : (21/12)nn est la valeur de départ.

Différentes façons de gérer les dynamiques

Créer un meilleur système pour gérer les dynamiques serait un des moyens par lesquels cet instrument pourrait être amélioré. Le système présent, qui demande de sélectionner manuellement parmi les trois échantillons disponibles pour chaque note (les échantillons doux, moyens, et forts), est plutôt malcommode. Que faire lorsqu’on veut créer un motif avec une montée graduelle des dynamiques ? Ou lorsqu’on veut une note dont la force est à mi-chemin entre le doux et le moyen ? Tout ça est bel et bien possible avec le système présent, mais les solutions sont un peu compliquées. Il faut réfléchir attentivement à l’amplitude à laquelle chaque note sera jouée, et créer plusieurs listes desquelles pourra émerger le motif voulu. Voici un exemple de motif avec une montée graduelle des dynamiques :

(
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();
)
Un motif avec une montée graduelle des dynamiques.
0:00
9:00

Nous commençons par créer deux listes, une première pour des valeurs décroissantes (les amplitudes des notes douces) et une autre pour des valeurs croissantes (les amplitudes des notes moyennes). Une fois ces deux listes créées, elles sont entrelacées avec l’opérateur lace(), ce qui génère une seule liste qui servira pour toutes les notes, autant les douces que les moyennes. Ensuite, dans la valeur \dur du Pbind, nous multiplions notre liste de durées par Pseq([0, 1], inf), ce qui signifie que chaque première note de toutes les paires de notes aura une durée de 0, ce qui signifie que deux notes seront jouées simultanément pour toutes les durées définies dans le premier Pseq utilisé dans \dur. Nous créons ensuite une liste de notes dans \num, et appliquons l’opérateur stutter(2) à cette liste parce que chaque note devra être jouée deux fois, simultanément.

Ensuite, dans \dyn, nous créons la liste [0, 1] qui se répétera infiniment, 0 étant l’échantillon doux et 1 l’échantillon moyen. Après que n * 2 notes aient été jouées, donc une fois que la liste complète des valeurs décroissantes et croissantes ait été jouée, nous additionnons 1 à la liste [0, 1] avec un second Pseq. Nous obtenons donc la liste [1, 2] qui se répétera aussi, ce qui signifie que nous sommes maintenant en train d’entrelacer les notes moyennes avec les notes fortes.

Cette façon de créer une montée graduelle des dynamiques est laborieuse, mais ça n’en fait pas nécessairement un mauvais système. Le système idéal aurait simplement besoin d’une valeur fractionnelle entre 0 et 1 pour la valeur \dyn, et jouerait automatiquement deux échantillons à des amplitudes variées, créant une dynamique précisément ajustée. 0 serait la note la plus douce et 1 la plus forte. Mais ce système aurait aussi ses désavantages. Premièrement, jouer deux notes simultanément ne produit pas exactement le même effet qu’une note seule — les notes simultanées produisent parfois un subtil effet de déphasage, particulièrement lorsqu’elles résonnent longtemps. L’effet peut être acceptable ou non selon les goûts et le contexte. Mais aussi, la collection d’échantillons contient quelques irrégularités : certaines notes sont jouées légèrement plus fort que d’autres. Il serait possible de créer une “liste d’exceptions” qui servirait à ajuster légèrement l’amplitude de certains enregistrements, mais ça commence à ressembler au genre de travail que je ferai si je me retrouve à utiliser cet échantillonneur sur un plus long laps de temps. Par conséquent, lorsque je crée des motifs musicaux, je trouve intéressant d’avoir le plein contrôle sur le choix des échantillons joués, au lieu d’utiliser un instrument qui fait tout à ma place.

Alors j’ai décidé pour l’instant de ne pas créer de système qui automatise le calcul des dynamiques fractionnelles. Peut-être que je le créerai un jour.

En conclusion

J’ai eu beaucoup de plaisir à créer ce projet et il m’a permis d’apprécier plus profondément l’étrange et confondante beauté de sclang. Si vous utilisez l’échantillonneur, il me ferait plaisir d’en recevoir des échos et aussi d’entendre vos compositions. N’hésitez pas à me contacter pour des commentaires ou des questions. Merci de votre lecture !