Accéder au contenu principal

Tutoriel : composer de la musique avec TensorFlow 2.0

Dans ce tutoriel, vous apprendrez à entraîner des modèles génératifs pour composer de la musique avec TensorFlow 2.0.
Actualisé 19 sept. 2026  · 15 min lire

Explorer avec l’IA

ChatGPTClaudePerplexity

introduction to tensorflow in python banner

Ce tutoriel est conçu autour de TensorFlow 2.0 en Python, avec l’API de haut niveau Keras, qui occupe un rôle accru dans TensorFlow 2.0. Pour aller plus loin sur TensorFlow 2.0, consultez le cours Introduction to TensorFlow in Python sur DataCamp. Pour une revue exhaustive de la littérature sur l’apprentissage profond appliqué à la musique, voir Briot, Hadjerest et Pachet (2019), à laquelle nous ferons référence tout au long du tutoriel.

Modèles génératifs

Les modèles d’apprentissage supervisé se divisent en deux catégories : les modèles discriminants et les modèles génératifs. Les modèles discriminants apprennent une frontière de décision et produisent une classification correspondante. Les modèles génératifs créent de nouveaux exemples d’une classe.

Un modèle discriminant appliqué à la musique peut classer des morceaux par genre. Un modèle génératif, lui, peut composer des morceaux d’un genre donné. Dans ce tutoriel, nous utiliserons des modèles génératifs pour composer de la musique.

Discriminant vs génératif
This figure depicts discriminative and generative models of music.

Représentation de la musique

Avant de définir et d’entraîner un modèle génératif, nous devons constituer un jeu de données. En musique, les données peuvent être représentées sous forme continue ou discrète. La forme continue la plus courante est le signal audio, généralement stocké en WAV. Nous n’utiliserons pas de formes continues ici, mais vous trouverez plus d’informations dans l’Annexe. Les formes discrètes les plus répandues incluent les fichiers MIDI (Musical Instrument Digital Interface), le pianoroll et le texte. Nous nous concentrerons sur les fichiers MIDI et en extrairons deux types d’objets symboliques : les notes et les accords.

Notes

Une note est une représentation symbolique d’un son. Dans notre cas, une note est décrite par sa hauteur et sa durée. La hauteur d’une note est liée à la fréquence d’oscillation de son onde sonore, mesurée en hertz (Hz). Plus la hauteur est élevée, plus l’onde comporte d’oscillations par seconde. La durée correspond au temps pendant lequel la note est jouée.

Les fichiers MIDI codent la hauteur d’une note par un entier compris entre 0 et 127. On peut aussi représenter une note par une lettre et un numéro d’octave. À l’intérieur d’une même octave, les hauteurs sont ordonnées de la plus basse à la plus haute fréquence comme suit :

  1. C
  2. C#/D♭
  3. D
  4. E♭/D#
  5. E
  6. F
  7. F#/G♭
  8. G
  9. A♭/G#
  10. A
  11. B♭/A#
  12. B

L’octave est indiquée en indice, par exemple le 4 dans A4 ou le 7 dans C7. Une octave plus haute correspond à une fréquence plus élevée. Si l’on prend une hauteur arbitraire Xi, alors Xi+1, une octave au-dessus, représente une onde de fréquence double de Xi.

En plus de la hauteur, nous utiliserons la durée de la note. La durée est une valeur relative, normalisée par la ronde (note entière). La note la plus longue est la « grande » (8 fois la ronde). La plus courte est la deux-cent-cinquante-sixième, soit 1/256e de ronde.

Accords

Un accord est une combinaison d’au moins deux notes jouées simultanément sur le même instrument. Si l’on considère une musique monodique — jouée sur un seul instrument — on peut identifier les accords en supposant que les notes jouées exactement en même temps appartiennent au même accord. Cette hypothèse n’est pas valide pour la musique polyphonique, où plusieurs instruments jouent en parallèle.

Dans le bloc de code ci-dessous, nous installons music21 puis importons le module converter pour analyser des fichiers MIDI. Nous chargeons et analysons une pièce pour guitare classique de Mauro Giuliani, puis appliquons la méthode .chordify(), qui reconstruit la suite d’accords de la partition en supposant que les notes simultanées forment un même accord.

!pip install music21
from music21 import converter

# Define data directory
data_dir = '../audio/'

# Parse MIDI file and convert notes to chords
score = converter.parse(data_dir+'giuliani.mid').chordify()

# Display as sheet music
print(score.show())

Après avoir construit la suite d’accords du fichier MIDI, nous pouvons l’afficher en partition avec la méthode .show(). Cela nécessite un logiciel de notation tel que MuseScore, disponible gratuitement.

Partition générée depuis un MIDI
This figure shows a MIDI file displayed as sheet music.

Nous pouvons aussi examiner et manipuler les éléments sous-jacents en texte via .show('text'). Au début de chaque ligne, l’offset entre crochets indique à combien de secondes dans la pièce l’élément apparaît. Notez qu’à la seconde 0, un seul élément est un accord. Les autres sont des métadonnées : instrument, tempo, tonalité, mesure. Nous réutiliserons la tonalité plus tard ; vous pouvez ignorer les autres pour l’instant.

 print(score.show('text'))
{0.0} <music21.instrument.Guitar 'Guitar'>
{0.0} <music21.tempo.MetronomeMark animato Quarter=120.0>
{0.0} <music21.key.Key of C major>
{0.0} <music21.meter.TimeSignature 4/4>
{0.0} <music21.chord.Chord E3 G3>
{1.0} <music21.chord.Chord B2 G3>
{2.0} <music21.chord.Chord D3 G3>
...
{46.0} <music21.chord.Chord A3>
...
{88.0} <music21.chord.Chord G2 E3 G3 C4>
{90.0} <music21.chord.Chord G2 D3 G3 B3>
{92.0} <music21.chord.Chord C3 E3 G3 C4>

Après les métadonnées, les éléments restants sont des accords, ordonnés comme ils apparaissent dans le morceau. Par exemple, <music21.chord.Chord E3 G3> est l’accord produit par les notes E3 et G3. De même, <music21.chord.Chord A3> est la note A3 jouée seule.

Pour certains modèles, nous utiliserons l’accord et sa durée, c’est-à-dire le temps pendant lequel il est joué. Nous pouvons récupérer ces informations via .elements et l’attribut .duration d’un accord quelconque, comme ci-dessous.

print(score.elements[10])
<music21.chord.Chord E3 A3>
print(score.elements[10].duration)
<music21.duration.Duration 1.0>

Encodage des notes et des accords

Dans ce tutoriel, nous utilisons des représentations discrètes de la musique décrites par des suites de notes, d’accords et leurs durées associées. Rappelons qu’un accord est une combinaison de notes et que le format MIDI autorise 128 notes distinctes. Si l’on étend l’espace des caractéristiques pour inclure tous les accords de deux notes, on ajoute 128!/[(128-2)!*2!], soit 8128 éléments. Les accords de trois notes ajoutent 341 376 éléments, et ceux de quatre notes 10 668 000 supplémentaires.

L’exemple précédent contient des notes et des accords de deux, trois et quatre notes. En encodage one-hot avec toutes les caractéristiques, il faudrait prédire sur des vecteurs de dimension 11 017 632. Il faut donc réfléchir soigneusement au choix d’encodage en génération musicale. On pourra aussi envisager la transposition de tonalité ou l’augmentation de données, traitées en Annexe.

Concernant l’encodage, Briot, Hadjerest et Pachet (2019) décrivent quatre options courantes dans les modèles génératifs de musique :

  1. Encodage one-hot
  2. Encodage many-hot
  3. Encodage multi-one-hot
  4. Encodage multi-many-hot

L’encodage one-hot représente chaque symbole musical (note ou accord) par un vecteur creux contenant un 1 à la position correspondante. L’encodage many-hot représente un accord — composé de plusieurs notes — en plaçant un 1 à la position de chacune des notes.

Dans le schéma ci-dessous, nous comparons many-hot et one-hot dans le cas « notes + accords de deux notes ». En many-hot, C4 et D4 sont des vecteurs de 128 éléments, tous à 0 sauf les positions C4 et D4 à 1. L’accord C4+D4 se construit en mettant des 1 aux positions C4 et D4.

Encodage des notes et des accords
This diagram illustrates the difference between one-hot and many-hot encoding.

La représentation one-hot des notes seules et des accords de deux notes utilise des vecteurs de 8256 éléments. Chaque vecteur est creux et contient un 1 à la position de la note ou de l’accord unique. Notez que l’encodage many-hot nécessite un modèle multi‑étiquette (plusieurs classes possibles en sortie), mais réduit fortement la taille des vecteurs d’entrée et de sortie.

La plupart des travaux utilisent encore le one-hot. Toutefois, Briot, Hadjerest et Pachet (2019) citent plusieurs modèles de génération qui exploitent le many-hot, notamment les systèmes RBMc, RNN-RBM et C-RBM, tous basés sur une architecture de machines de Boltzmann restreintes (RBM).

Les encodages multi-one-hot et multi-many-hot généralisent les précédents au cas de la musique polyphonique — plusieurs instruments jouant simultanément. Nous nous restreindrons à la musique monodique et n’irons pas plus loin sur ces options.

Collecte et préparation des données

Prochaine étape : constituer un jeu de données pour entraîner un modèle génératif. Nous utiliserons des données du Mutopia Project, qui contient 2124 pièces au format MIDI dans le domaine public. Le site étant organisé par instrument, un simple script Python permet d’extraire toutes les pièces pour guitare, que nous utiliserons pour l’entraînement. Un aperçu de la démarche est donné en Annexe.

Supposons que nous ayons enregistré tous les fichiers .mid de la section guitare dans un répertoire save_dir. L’étape suivante est de charger chaque fichier .mid dans music21 en tant qu’objet stream.

import os
from music21 import converter, pitch, interval

# Define save directory
save_dir = '../guitar/'

# Identify list of MIDI files
songList = os.listdir(save_dir)

# Create empty list for scores
originalScores = []

# Load and make list of stream objects
for song in songList:
    score = converter.parse(save_dir+song)
    originalScores.append(score)

Dans une optique de réduction de dimension, nous nous limiterons aux pièces de Mauro Giuliani. On ajuste le code ci-dessus comme suit.

# Identify list of MIDI files
songList = os.listdir(save_dir)
songList = [song for song in songList if song.lower().find('giuliani')>-1]

Il nous reste quatre actions avant l’entraînement :

  1. Retirer la musique polyphonique et appliquer .chordify() aux stream restants.
  2. Extraire la suite des notes, accords et durées.
  3. Construire une correspondance entre notes/accords/durées et des entiers, et l’inverse.
  4. Diviser les flux en séquences de longueur fixe utilisables comme entrées des modèles.

Commençons par retirer la polyphonie. On partitionne chaque stream par instrument et on vérifie qu’il n’y a qu’une seule partition — donc un seul instrument. Voyez ci-dessous.

from music21 import instrument

# Define function to test whether stream is monotonic
def monophonic(stream):
    try:
        length = len(instrument.partitionByInstrument(stream).parts)
    except:
        length = 0
    return length == 1

# Merge notes into chords
originalScores = [song.chordify() for song in originalScores]

Si l’on applique .chordify() — illustré ci-dessous — sans retirer la polyphonie, on fusionnera par erreur des notes de différents instruments dans un même accord.

Conversion des notes en accords
This figure shows notes being converted to chords.

Nous devons maintenant extraire notes, accords et durées de chaque stream. Définissons trois listes vides : originalChords, originalDurations, originalKeys. Pour chaque flux, nous recueillons toutes les notes, accords, durées et la tonalité de la pièce.

from music21 import note, chord

# Define empty lists of lists
originalChords = [[] for _ in originalScores]
originalDurations = [[] for _ in originalScores]
originalKeys = []

# Extract notes, chords, durations, and keys
for i, song in enumerate(originalScores):
    originalKeys.append(str(song.analyze('key')))
    for element in song:
        if isinstance(element, note.Note):
            originalChords[i].append(element.pitch)
            originalDurations[i].append(element.duration.quarterLength)
        elif isinstance(element, chord.Chord):
            originalChords[i].append('.'.join(str(n) for n in element.pitches))
            originalDurations[i].append(element.duration.quarterLength)
    print(str(i))

Nous allons encore réduire la dimension en ne conservant que les pièces en do majeur, la tonalité la plus fréquente dans notre ensemble. Dans la suite, par simplicité, « accord » désignera à la fois accords et notes.

# Create list of chords and durations from songs in C major
cMajorChords = [c for (c, k) in zip(originalChords, originalKeys) if (k == 'C major')]
cMajorDurations = [c for (c, k) in zip(originalDurations, originalKeys) if (k == 'C major')]

Étape suivante : identifier l’ensemble unique des accords et des durées, puis construire des dictionnaires qui les mappent vers des entiers. En affichant le nombre de notes et accords uniques, on constate que les réductions appliquées ramènent ce total à un niveau gérable : 267.

# Map unique chords to integers
uniqueChords = np.unique([i for s in originalChords for i in s])
chordToInt = dict(zip(uniqueChords, list(range(0, len(uniqueChords)))))

# Map unique durations to integers
uniqueDurations = np.unique([i for s in originalDurations for i in s])
durationToInt = dict(zip(uniqueDurations, list(range(0, len(uniqueDurations)))))

# Print number of unique notes and chords
print(len(uniqueChords))
267

# Print number of unique durations
print(len(uniqueDurations))
9

Une fois le modèle entraîné et des prédictions obtenues, nous devrons remapper les entiers vers notes, accords et durées. On inverse ci-dessous chordToInt et durationToInt.

# Invert chord and duration dictionaries
intToChord = {i: c for c, i in chordToInt.items()}
intToDuration = {i: c for c, i in durationToInt.items()}

Enfin, définissons nos séquences d’entraînement : 32 notes/accords consécutifs avec leurs durées. Commençons par des modèles de type autoencoder, où caractéristiques et cibles sont identiques.

# Define sequence length
sequenceLength = 32

# Define empty arrays for train data
trainChords = []
trainDurations = []

# Construct training sequences for chords and durations
for s in range(len(cMajorChords)):
    chordList = [chordToInt[c] for c in cMajorChords[s]]
    durationList = [durationToInt[d] for d in cMajorDurations[s]]
    for i in range(len(chordList) - sequenceLength):
        trainChords.append(chordList[i:i+sequenceLength])
        trainDurations.append(durationList[i:i+sequenceLength])

Génération musicale

Dans cette section, nous allons utiliser le jeu de données préparé pour générer de la musique. Nous étudierons trois approches, de la plus simple à la plus avancée :

  1. L’autoencoder
  2. Le variational autoencoder (VAE)
  3. Le modèle à mémoire longue courte (LSTM)

Autoencoders

Un autoencoder se compose de deux réseaux empilés verticalement et reliés par un vecteur latent. Les entrées passent d’abord dans un modèle d’encodeur, souvent une ou plusieurs couches denses. La dernière couche de l’encodeur est le vecteur latent, un goulot d’étranglement qui compresse les caractéristiques en un petit nombre de variables latentes.

Le vecteur latent est relié à un décodeur, qui « décompresse » les données. Le nombre de nœuds augmente au fil des couches du décodeur. La couche de sortie finale a les mêmes dimensions que l’entrée de l’encodeur.

Contrairement aux modèles discriminants, qui cherchent une frontière de décision, les autoencoders sont entraînés à reconstruire l’entrée — ici, un morceau — le plus fidèlement possible sous la contrainte de la taille du latent. Ainsi, les autoencoders utilisent simplement les entrées comme cibles.

Nous allons utiliser un autoencoder pour construire un premier modèle génératif. Pour simplifier au maximum, nous adopterons l’architecture du modèle MiniBach introduit par Briot, Hadjerest et Pachet (2019) comme simplification de DeepBach. Pour un autre exemple d’autoencoder de génération musicale, voir DeepHear.

Nos données d’entrée, telles que construites, sont des vecteurs one-hot représentant notes et accords. Nous ignorerons les durées pour l’instant. Suivant MiniBach, nous allons d’abord aplatir le vecteur d’entrée.

# Convert to one-hot encoding and swap chord and sequence dimensions
trainChords = tf.keras.utils.to_categorical(trainChords).transpose(0,2,1)

# Convert data to numpy array of type float
trainChords = np.array(trainChords, np.float)

# Flatten sequence of chords into single dimension
trainChordsFlat = trainChords.reshape(nSamples, nChordsSequence)

Définissons ensuite le nombre d’échantillons nSamples, le nombre d’accords/notes nChords et la taille de l’entrée inputDim. Nous fixons le nombre de dimensions latentes latentDim à 2. Un latent de faible dimension réduira la performance, mais permettra de visualiser facilement l’espace latent.

# Define number of samples, chords and notes, and input dimension
nSamples = trainChords.shape[0]
nChords = trainChords.shape[1]
inputDim = nChords * sequenceLength

# Set number of latent features
latentDim = 2

Nous définissons maintenant l’architecture : la couche d’entrée de l’encodeur encoderInput et la couche d’entrée du décodeur latent. Notez que latent est aussi la sortie de l’encodeur. Nous ajoutons une couche dense encoded reliant l’entrée au latent, puis une couche dense decoded reliant le latent à la sortie.

Enfin, nous définissons un modèle décodeur decoder et l’autoencoder autoencoder. La méthode .Model() prend des inputs et des outputs. Pour decoder, l’entrée est le vecteur latent et la sortie decoded. Pour l’autoencoder complet, l’entrée est encoderInput et la sortie est le decoder appliqué à la sortie de l’encodeur encoded.

# Define encoder input shape
encoderInput = tf.keras.layers.Input(shape = (inputDim))

# Define decoder input shape
latent = tf.keras.layers.Input(shape = (latentDim))

# Define dense encoding layer connecting input to latent vector
encoded = tf.keras.layers.Dense(latentDim, activation = 'tanh')(encoderInput)

# Define dense decoding layer connecting latent vector to output
decoded = tf.keras.layers.Dense(inputDim, activation = 'sigmoid')(latent)

# Define the encoder and decoder models
encoder = tf.keras.Model(encoderInput, encoded)
decoder = tf.keras.Model(latent, decoded)

# Define autoencoder model
autoencoder = tf.keras.Model(encoderInput, decoder(encoded))

L’architecture générique d’un autoencoder est illustrée ci-dessous. La moitié supérieure est l’encodeur, l’inférieure le décodeur, reliés par le latent.

Architecture d’un autoencoder
This figure shows the architecture for an autoencoder.

Il ne reste plus qu’à compiler puis entraîner avec .fit(). Deux points importants :

  1. Les entrées sont des 0/1 et les sorties prédites sont réelles entre 0 et 1. Utilisons une perte binary_crossentropy.
  2. Les valeurs d’entrée et les cibles sont identiques : trainChordsFlat.
# Compile autoencoder model
autoencoder.compile(loss = 'binary_crossentropy', learning_rate = 0.01, optimizer='rmsprop')

# Train autoencoder
autoencoder.fit(trainChordsFlat, trainChordsFlat, epochs = 500)

Dernière étape : générer de la musique. Avec une longueur de séquence de 32, l’autoencoder prend 32 accords/notes en entrée et produit un « morceau » fixe de 32 éléments. L’autoencoder génère des sorties proches des entrées ; pour créer du neuf, nous transmettrons au decoder un vecteur latent aléatoire.

# Generate chords from randomly generated latent vector
generatedChords = decoder(np.random.normal(size=(1,latentDim))).numpy().reshape(nChords, sequenceLength).argmax(0)

Nous remodelons la sortie en un tableau aux mêmes dimensions que l’entrée, puis appliquons argmax() sur la première dimension. On obtient un entier par position, correspondant à un accord, que nous identifions ci-dessous.

# Identify chord sequence from integer sequence
chordSequence = [intToChord[c] for c in generatedChords]

Enfin, créons un objet stream avec music21, définissons la guitare comme instrument, ajoutons la suite d’accords générés, puis exportons en MIDI.

# Set location to save generated music
generated_dir = '../generated/'

# Generate stream with guitar as instrument
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())

# Append notes and chords to stream object
for j in range(len(chordSequence)):
    try:
        generatedStream.append(note.Note(chordSequence[j].replace('.', ' ')))
    except:
        generatedStream.append(chord.Chord(chordSequence[j].replace('.', ' ')))

generatedStream.write('midi', fp=generated_dir+'autoencoder.mid')

Le gif ci-dessous illustre une partie de l’entraînement d’un autoencoder. À chaque étape, la partition affichée est la reconstruction associée à un vecteur latent donné. Chaque image regroupe 5 époques d’entraînement.

Autoencoder : évolution des suites de notes et d’accords
This figure shows shifts in the latent space over time.

Nous pouvons aussi visualiser l’évolution de l’espace latent pendant l’entraînement (gif ci-dessous). L’espace est étroit et dérive au fil des époques, ce qui complique l’échantillonnage aléatoire de latents de qualité — et pose d’autant plus problème avec un latent de dimension plus élevée. Nous verrons au chapitre suivant comment un variational autoencoder (VAE) corrige cela.

Autoencoder : évolution de l’espace latent
This figure shows shifts in the latent space over time.

Terminons par l’écoute. La suite d’accords du morceau original (tiré aléatoirement de l’entraînement) est donnée en premier. Nous générons ensuite une suite à partir de l’autoencoder, en utilisant le vecteur latent associé à la suite d’accords originale ; le morceau obtenu figure en dessous.

Exemple : morceau original
Exemple : morceau généré

Variational autoencoder

Si les autoencoders excellent pour le débruitage, la compression et la décompression, ils sont moins adaptés à la génération. Les variational autoencoders (VAE), introduits par Kingma et Welling (2017), corrigent ces faiblesses et figurent parmi les méthodes les plus utilisées pour la génération musicale. Briot, Hadjerest et Pachet (2019) citent quatre systèmes de génération fondés sur des VAE : MusicVAE, VRAE, VRASH et GSLR-VAE. Les améliorations clés sont :

  1. Au lieu d’associer chaque entrée à un point dans l’espace latent, un VAE l’associe à une distribution normale caractérisée par une moyenne et une variance.
  2. Le VAE contraint la moyenne et le logarithme naturel de la variance à rester proches de 0.

On remplace donc le vecteur latent de l’autoencoder par trois couches :

  1. Une couche de moyenne
  2. Une couche de (log) variance
  3. Une couche d’échantillonnage

Chaque entrée est mappée à une moyenne et au log‑variance, ce qui définit une loi normale. La couche d’échantillonnage tire ensuite des points aléatoires de cette distribution.

Le code ci-dessous définit une couche d’échantillonnage. Elle prend en entrée une moyenne et un log‑variance. Elle tire un tenseur epsilon d’une loi normale standard (moyenne et log‑variance à 0) via le sous-module random de tensorflow. Nous transformons ensuite ces tirages en échantillons de distributions paramétrées par mean et logVar en les multipliant par l’écart‑type (exp(logVar/2)) puis en ajoutant la moyenne.

# Define function to generate sampling layer.
def sampling(params):
    mean, logVar = params
    batchSize = tf.shape(mean)[0]
    latentDim = tf.shape(mean)[1]
    epsilon = tf.random.normal(shape=(batchSize, latentDim))
    return mean + tf.exp(logVar / 2.0) * epsilon

Nous pouvons maintenant modifier l’autoencoder pour intégrer mean, logVar et la couche sampling. Notez que la couche encoded prend désormais deux entrées et que l’encoder a trois sorties.

# Define the input and latent layer shapes.
encoderInput = tf.keras.layers.Input(shape = (inputDim))
latent = tf.keras.layers.Input(shape = (latentDim))

# Add mean and log variance layers.
mean = tf.keras.layers.Dense(latentDim)(encoderInput)
logVar = tf.keras.layers.Dense(latentDim)(encoderInput)

# Add sampling layer.
encoded = tf.keras.layers.Lambda(sampling, output_shape=(latentDim,))([mean, logVar])

# Define decoder layer.
decoded = tf.keras.layers.Dense(inputDim, activation = 'sigmoid')(latent)

# Define encoder and decoder layers.
encoder = tf.keras.Model(encoderInput, [mean, logVar, encoded])
decoder = tf.keras.Model(latent, decoded)

# Define variational autoencoder model.
vae = tf.keras.Model(encoderInput, decoder(encoded))

Comme pour l’autoencoder, l’état latent est la sortie de encoded. La différence majeure : au lieu d’un point unique, nous échantillonnons des valeurs aléatoires issues d’une normale de paramètres mean et logVar. La même entrée correspond donc à une distribution d’états latents.

Dernière étape : ajuster la fonction de perte pour contraindre mean et logVar. Nous calculons la divergence de Kullback‑Leibler (KL) entre chaque distribution latente et la normale standard (moyenne et log‑variance à 0). Plus la moyenne et le log‑variance s’éloignent de 0, plus le terme KL est pénalisant.

Pourquoi forcer ces distributions à se rapprocher d’une normale standard ? Parce que nous souhaitons échantillonner des latents pour générer de la musique. En imposant cette contrainte via la KL, de simples tirages indépendants d’une normale standard fourniront des latents susceptibles de générer des morceaux de bonne qualité.

Ci-dessous, nous modifions la perte pour intégrer le terme KL. Le premier composant est la binary crossentropy entre vae.inputs[0] et vae.outputs[0] (perte de « reconstruction »). Le second est la KL moyenne entre les distributions latentes et la normale standard. Nous combinons les deux en une perte totale vaeLoss, l’ajoutons au modèle, compilons et entraînons.

# Define the reconstruction loss.
reconstructionLoss = tf.keras.losses.binary_crossentropy(vae.inputs[0], vae.outputs[0])

# Define the Kullback-Liebler divergence term.
klLoss = -0.5 * tf.reduce_mean(1 + logVar - tf.square(mean) - tf.exp(logVar), axis = -1)

# Combine the reconstruction and KL loss terms.
vaeLoss = reconstructionLoss + klLoss

# Add the loss to the model.
vae.add_loss(vaeLoss)

# Compile the model.
vae.compile(batch_size = batchSize, learning_rate = 0.01, optimizer='rmsprop')

# Train the model.
vae.fit(trainChordsFlat, epochs = 500)

Précédemment, nous avons vu que l’espace latent de l’autoencoder était peu « bien comporté ». Un VAE vise justement à corriger ce défaut en génération. Ci-dessous, l’espace latent du VAE : on observe une dispersion significative sur les deux axes, centrée autour de 0, sans dérive au fil des époques. Il devient bien plus simple d’échantillonner des latents de qualité.

VAE : évolution de l’espace latent
This figure shows shifts in the latent space over time.

Passons à la génération. Nous tirons un latent aléatoire d’une normale standard, le passons au décodeur, remodelons la sortie et prenons l’argmax de chaque colonne. On obtient une suite de 32 entiers correspondant aux notes/accords, que nous identifions via intToChord. Nous créons ensuite un stream, ajoutons la guitare, y mettons la suite et exportons en MIDI.

# Generate integers from randomly drawn latent state.
generatedChords = decoder(np.random.normal(size=(1,latentDim))).numpy().reshape(nChords, sequenceLength).argmax(0)

# Identify chords associated with integers.
chordSequence = [intToChord[c] for c in generatedChords]

# Initialize stream with guitar as instrument.
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())

# Append notes and chords and export to MIDI file
for j in range(len(chordSequence)):
    try:
        generatedStream.append(note.Note(chordSequence[j].replace('.', ' ')))
    except:
        generatedStream.append(chord.Chord(chordSequence[j].replace('.', ' ')))

generatedStream.write('midi', fp=generated_dir+'vae.mid')

Les trois morceaux ci-dessous ont été générés par le modèle. Les deux premiers proviennent de latents associés à des suites d’accords du jeu d’entraînement. Le troisième est une combinaison linéaire des latents des morceaux n°1 et n°2. Comme le VAE structure bien l’espace latent, une combinaison linéaire a de bonnes chances de produire un latent de qualité.

Exemple : morceau généré n°1
Exemple : morceau généré n°2
Exemple : combinaison linéaire des morceaux n°1 et n°2

Un constat sur les morceaux issus des autoencoders et des VAE : la mélodie reste souvent peu marquée. On peut améliorer cela en ajoutant des couches et en élargissant le jeu d’entraînement. Autre voie : utiliser un modèle conçu pour les données séquentielles, comme nous allons le faire maintenant.

Long short-term memory (LSTM)

Le dernier modèle étudié est le LSTM (long short-term memory), un type de réseau de neurones récurrent (RNN) conçu pour éviter le problème du gradient qui s’annule. Briot, Hadjerest et Pachet (2019) relèvent que les modèles récurrents sont les plus utilisés pour la génération musicale, avec plus de 20 systèmes fondés sur RNN ou LSTM, dont BachBot, DeepBach, Performance-RNN et Hexahedria.

Les bénéfices d’un LSTM face aux autoencoders :

  1. Il est conçu pour traiter des séquences.
  2. Il peut générer des suites musicales de longueur arbitraire.

Contrairement aux autoencoders et aux VAE, qui utilisent les entrées comme cibles, les modèles LSTM de génération sont entraînés à prédire la note ou l’accord suivant. Il faut donc reconstruire nos ensembles d’entraînement et de cibles, comme ci-dessous. Puisque le LSTM réduit le nombre de paramètres à entraîner, nous y intégrons aussi les durées.

# Set sequence length
sequenceLength = 32

# Define empty array for train data
trainChords = []
trainDurations = []
targetChords = []
targetDurations = []

# Construct train and target sequences for chords and durations
for s in range(len(cMajorChords)):
    chordList = [chordToInt[c] for c in cMajorChords[s]]
    durationList = [durationToInt[d] for d in cMajorDurations[s]]
    for i in range(len(chordList) - sequenceLength):
        trainChords.append(chordList[i:i+sequenceLength])
        trainDurations.append(durationList[i:i+sequenceLength])
        targetChords.append(chordList[i+1])
        targetDurations.append(durationList[i+1])

Remarquez que trainChords et trainDurations sont définis comme plus haut. Nous avons simplement ajouté targetChords et targetDurations, qui contiennent l’accord et la durée suivant la séquence d’entraînement. Nous définissons ensuite le nombre d’échantillons, d’accords et de durées, ainsi que la dimension d’entrée. Nous utiliserons des couches d’embedding distinctes pour accords et durées.

# Define number of samples, notes and chords, and durations
nSamples = trainChords.shape[0]
nChords = trainChords.shape[1]
nDurations = trainDurations.shape[1]

# Set the input dimension
inputDim = nChords * sequenceLength

# Set the embedding layer dimension
embedDim = 64

Définissons l’architecture. Nous utiliserons un modèle à deux entrées et deux sorties, pour prédire à la fois accords et durées. On crée les couches d’entrée chordInput et durationInput, puis on les passe à des couches d’embedding chordEmbedding et durationEmbedding — des tables d’index associant un vecteur dense de 64 éléments à chaque accord ou durée. Les embeddings remplacent des vecteurs clairsemés one-hot par des représentations denses où des éléments proches musicalement ont des valeurs plus proches.

Les sorties des deux embeddings sont concaténées dans mergeLayer. On transmet ensuite cette sortie à deux couches LSTM. La première fixe return_sequences à True pour récupérer les états intermédiaires, ce qui peut améliorer les performances (souvent via une couche d’attention). Enfin, nous passons la sortie du LSTM à une couche dense, puis à deux couches de sortie : chordOutput et durationOutput. Nous définissons un modèle fonctionnel keras avec ces deux entrées et deux sorties.

# Define input layers
chordInput = tf.keras.layers.Input(shape = (None,))
durationInput = tf.keras.layers.Input(shape = (None,))

# Define embedding layers
chordEmbedding = tf.keras.layers.Embedding(nChords, embedDim, input_length = sequenceLength)(chordInput)
durationEmbedding = tf.keras.layers.Embedding(nDurations, embedDim, input_length = sequenceLength)(durationInput)

# Merge embedding layers using a concatenation layer
mergeLayer = tf.keras.layers.Concatenate(axis=1)([chordEmbedding, durationEmbedding])

# Define LSTM layer
lstmLayer = tf.keras.layers.LSTM(512, return_sequences=True)(mergeLayer)

# Define dense layer
denseLayer = tf.keras.layers.Dense(256)(lstmLayer)

# Define output layers
chordOutput = tf.keras.layers.Dense(nChords, activation = 'softmax')(denseLayer)
durationOutput = tf.keras.layers.Dense(nDurations, activation = 'softmax')(denseLayer)

# Define model
lstm = tf.keras.Model(inputs = [chordInput, durationInput], outputs = [chordOutput, durationOutput])

Avec le modèle défini, nous compilons et entraînons. Nous restons ici volontairement simples, mais selon les cas, il sera pertinent d’ajouter de la régularisation et des couches denses/LSTM supplémentaires. Notez les deux entrées et deux sorties, comme dans le schéma ci-dessous. Pour en savoir plus sur les modèles à entrées/sorties multiples, voir Introduction to TensorFlow in Python.

Architecture du modèle LSTM
This figure shows the architecture of the LSTM model.
# Compile the model
lstm.compile(loss='categorical_crossentropy', optimizer='rmsprop')

# Train the model
lstm.fit([trainChords, trainDurations], [targetChords, targetDurations],
            epochs=500, batch_size=64)

Une fois l’entraînement terminé, nous pouvons générer de nouveaux morceaux avec le LSTM. Nous fournissons une séquence initiale de 32 accords et durées pour produire une première prédiction, que nous ajoutons aux séries d’accords et de durées. Nous prédisons ensuite à nouveau à partir des 32 derniers éléments, et ainsi de suite. Un avantage du LSTM : on peut itérer indéfiniment et générer des morceaux de longueur arbitraire.

# Define initial chord and duration sequences
initialChords = np.expand_dims(trainChords[0,:].copy(), 0)
initialDurations = np.expand_dims(trainDurations[0,:].copy(), 0)

# Define function to predict chords and durations
def predictChords(chordSequence, durationSequence):
    predictedChords, predictedDurations = model.predict(model.predict([chordSequence, durationSequence]))
    return np.argmax(predictedChords), np.argmax(predictedDurations)

# Define empty lists for generated chords and durations
newChords, newDurations = [], []

# Generate chords and durations using 500 rounds of prediction
for j in range(500):
    newChord, newDuration = predictChords(initialChords, initialDurations)
    newChords.append(newChord)
    newDurations.append(newDuration)
    initialChords[0][:-1] = initialChords[0][1:]
    initialChords[0][-1] = newChord
    initialDurations[0][:-1] = initialDurations[0][1:]
    initialDurations[0][-1] = newDuration

Dernière étape : exporter la musique générée en MIDI avec music21. Le code est quasi identique à celui des autoencoders, à une différence près : nous ajoutons désormais à la fois accords et durées au stream.

# Create stream object and add guitar as instrument
generatedStream = stream.Stream()
generatedStream.append(instrument.Guitar())

# Add notes and durations to stream
for j in range(len(chordSequence)):
    try:
        generatedStream.append(note.Note(chordSequence[j].replace('.', ' '), quarterType = durationSequence[j]))
    except:
        generatedStream.append(chord.Chord(chordSequence[j].replace('.', ' '), quarterType = durationSequence[j]))

# Export as MIDI file
generatedStream.write('midi', fp=generated_dir+'lstm.mid')

Avec les autoencoders et VAE, nous peinions à générer des mélodies et étions limités à des longueurs fixes. Avec un LSTM, nous pouvons générer des suites de longueur arbitraire par itérations et obtenir plus facilement de meilleures mélodies. Voyez ci-dessous des exemples, amorcés par une séquence initiale issue du jeu d’entraînement.

Exemple : morceau généré n°1
Exemple : morceau généré n°2
Exemple : morceau généré n°3

   introduction to tensorflow in python course banner

Points complémentaires

Données

L’un des grands défis pour développer des modèles de machine learning en musique est la disponibilité des jeux de données, la musique n’étant en général ni libre ni disponible légalement en téléchargement. Cela a empêché la communauté de converger vers un jeu d’entraînement standard (à l’instar de MNIST pour la vision). Il existe toutefois quelques projets fournissant soit de l’audio brut, soit une notation symbolique, soit des caractéristiques dérivées pour un grand nombre de morceaux. En voici une sélection.

  1. Mutopia Project. Une archive d’œuvres en domaine public ou sous Creative Commons en formats MIDI, PDF et LilyPond. Plus de 2000 morceaux, organisés par instrument, compositeur, style et collection.
  2. The Million Song Dataset. Des caractéristiques audio et métadonnées pour plus de 300 Go de musique populaire, sans les pistes audio.
  3. Free Music Archive. Audio, labels de genre et caractéristiques pour plus de 100 000 pistes, téléchargeables librement et légalement.

Échantillonnage vs déterminisme

Pour générer avec le LSTM, nous avons adopté une approche déterministe de sélection des accords et durées. Le modèle prédisait des probabilités pour chacun des 267 accords et 9 durées, et nous retenions la probabilité maximale. De même, pour les autoencoders et VAE, nous retenions les accords à la valeur de sortie la plus élevée.

Une alternative consiste à échantillonner dans la distribution de sortie au lieu de prendre l’argmax. La génération devient alors stochastique : un autoencoder ou un VAE avec le même latent génèrera des morceaux différents à chaque exécution. De même, une même séquence d’entrée dans un LSTM pourra mener à des morceaux distincts.

Le bloc ci-dessous montre comment rendre stochastiques les prédictions d’accords et de durées en modifiant predictChords(). Nous remplaçons l’argmax() par un tirage aléatoire pondéré par les probabilités.

# Define function to generate chords and durations stochastically
def predictChords(chordSequence, durationSequence):
    predictedChords, predictedDurations = model.predict([chordSequence, durationSequence])
    return np.random.choice(range(nChords), p = predictedChords), np.random.choice(range(nDurations), p = predictedDurations)

Pour une discussion approfondie des avantages des approches stochastiques et déterministes en génération musicale, voir la section 6.6 de Briot, Hadjerest et Pachet (2019).

Embeddings

Lors de l’entraînement du LSTM, nous avons utilisé une couche d’embeddings mappant chaque accord et durée vers un vecteur dense de 64 éléments. Les embeddings évitent des vecteurs d’entrée clairsemés et de grande dimension, au profit d’une représentation compacte qui capture les relations entre accords.

Souvent, il ne sera pas pertinent d’entraîner cette couche d’embedding, faute d’assez de données. Comme pour le texte, on pourra recourir à des embeddings pré‑entraînés ou appris de façon non supervisée, ce qui rendra l’entraînement possible là où il ne le serait pas autrement.

Voir Chuan, Agres et Herremans (2018) pour un aperçu de la construction d’embeddings via apprentissage non supervisé.

Generative adversarial networks

Nous avons présenté autoencoders, VAE et LSTM pour la génération musicale. Une autre option, couronnée de succès dans d’autres domaines, est le réseau antagoniste génératif (GAN). Un GAN combine un réseau discriminant avec un réseau génératif. Le générateur crée des échantillons (p. ex. des suites d’accords comme nos modèles LSTM/autoencoder). Ces échantillons sont mélangés à de « vrais » exemples — des suites issues de morceaux réels. Le discriminateur cherche à distinguer vrais et générés. Le générateur tente de le tromper. L’apprentissage se poursuit jusqu’à un équilibre où ni l’un ni l’autre ne progresse davantage.

Pour celles et ceux qui souhaitent appliquer des GANs à la musique :

  1. MuseGAN vise la génération de musique polyphonique avec des GANs. Le projet inclut des modèles pré‑entraînés et 174 154 morceaux nettoyés au format pianoroll.

  2. Magenta est un projet open source dédié aux modèles génératifs pour la création (dont la musique). Vous pouvez explorer ses outils via un notebook Colab.

Ressources

Pour approfondir TensorFlow 2.0, voir le cours Introduction to TensorFlow in Python sur DataCamp. Pour une revue récente et complète des méthodes de génération musicale par apprentissage profond, voir Briot, Hadjerest et Pachet (2019). Pour d’autres tutoriels sur la génération musicale avec l’apprentissage profond, voir 1, 2, 3, 4 et 5, un traitement complet en ouvrage sur les modèles génératifs.

Annexe

Représentation continue

Les fichiers WAV sont la forme continue la plus courante car ils contiennent un signal audio brut et non compressé. À l’inverse, les fichiers mp3 réduisent la taille en supprimant des informations du signal.

Le code ci-dessous montre comment extraire la fréquence d’échantillonnage rate et le signal audio brut signal d’un WAV contenant une pièce de Mozart. La fréquence d’échantillonnage est le nombre d’échantillons du signal par seconde : ici 44 100.

from scipy.io import wavfile

# Define data directory
data_dir = '../audio/'

# Extract sample rate and signal
rate, signal = wavfile.read(data_dir+'mozart.wav')

# Bound signal between -1 and 1
normalized_signal = signal / abs(max(signal))

Le signal brut signal est un tenseur S x C, où S est le nombre d’échantillons et C le nombre de canaux. Pour simplifier, considérons C = 1. La figure ci-dessous montre un intervalle de trois secondes de signal.

This figure shows a plot of a normalized audio signal extracted from a WAV file.

De manière générale, entraîner un modèle sur un signal audio brut est nettement plus complexe que d’utiliser une représentation discrète. Une enquête récente sur 32 méthodes de génération musicale par deep learning montre que seules trois utilisent des représentations continues :

  1. Audio Style Transfer
  2. DeepAuto-Controller
  3. WaveNet

Transposition de tonalité

La transposition (changement de tonalité) peut servir à la réduction de dimension comme à l’augmentation de données. Elle consiste à décaler la hauteur de chaque note d’un intervalle fixe. Si une séquence contient C3 suivi de D3 et que l’on transpose de deux demi‑tons vers le haut, C3 devient D3 et D3 devient E3. Pour des modèles utilisant la transposition, voir Lim, Rhyu et Lee (2018) et Sturm et al. (2016).

Utilisée pour réduire la dimension, la transposition vise à minimiser le nombre total de notes/accords uniques en décalant chaque morceau vers une même tonalité. La musique étant souvent écrite autour de groupes de hauteurs appelés tonalités qui « sonnent bien » ensemble, on peut commodément ramener toutes les pièces à la même tonalité.

Cette fois, nous importons trois sous‑modules de music21 :

  1. converter pour charger un fichier MIDI et le convertir en stream.
  2. pitch pour créer la hauteur cible.
  3. interval pour calculer la distance entre tonalité originale et cible.
from music21 import converter, pitch, interval

# Define data directory
data_dir = '../audio/'

# Parse MIDI file
score = converter.parse(data_dir+'giuliani.mid')

# Identify and print original key
key = score.analyze('key')
print(key)
C major

# Compute interval between original key and target key
keyInterval = interval.Interval(key.tonic, pitch.Pitch('F'))

# Transpose song into F major
newScore = score.transpose(keyInterval)

# Print new key
print(newScore.analyze('key'))
F major

Nous appliquons .analyze('key') à score pour identifier la tonalité du stream : do majeur. Nous calculons ensuite l’intervalle vers la tonalité cible, fa majeur. Nous accédons à la tonique via .tonic et créons la hauteur cible via pitch.Pitch('F'). keyInterval donne la distance entre tonalité d’origine et cible.

Enfin, nous transposons avec .transpose() en lui passant keyInterval. L’impression confirme la nouvelle tonalité : fa majeur.

Transposition d’octave

Au‑delà de la tonalité, on peut aussi transposer l’octave vers le haut ou le bas. Plutôt que C majeur vers F majeur, on peut transposer C majeur vers C majeur à une octave inférieure ou supérieure. En ciblant un même ensemble d’octaves pour chaque morceau, on regroupe notes et accords, ce qui réduit la dimension et simplifie l’apprentissage. Dans le code précédent, il suffit de préciser l’octave de la hauteur cible, par exemple pitch.Pitch('F3') au lieu de pitch.Pitch('F').

Invariance à la tonalité et à l’octave

En alternative à la réduction de dimension, l’augmentation de données élargit le jeu en modifiant les échantillons existants. On peut transposer toutes les pièces vers toutes les tonalités et toutes les octaves pour rendre le modèle invariant. Voir Briot, Hadjerest et Pachet (2019) pour une discussion des deux usages.

Collecte des données

Nous collecterons les fichiers MIDI depuis le Project Mutopia. Avant tout scraping, vérifions le fichier robots à https://www.mutopiaproject.org/robots.txt pour respecter les règles d’accès automatisé. Au moment de la rédaction, il contenait le texte ci-dessous. User-agent: * indique que la règle s’applique à tous les agents ; Disallow: (vide) signifie aucune restriction au scraping.

# Allow crawling of all content
User-agent: *
Disallow:

Nous importerons urlopen et urlretrieve de urllib.request pour envoyer des requêtes sur chaque page de la section guitare et télécharger chaque fichier MIDI lié. Nous utiliserons BeautifulSoup pour analyser le HTML renvoyé par urlopen et identifier les liens MIDI. Enfin, time servira à faire une pause de 10 secondes entre les téléchargements pour ménager les ressources du site.

This figure shows a page in the guitar section of the Mutopia website with MIDI Files identified.

À chaque itération de la boucle principale, on vérifie si le nombre de liens MIDI linkCount sur la page dépasse 0. Si oui, on construit l’URL de la page suivante url avec url0, url1 et le numéro de départ songNumber. On ouvre l’URL, on parse le HTML, on récupère tous les liens, on remet linkCount à 0 puis on parcourt chaque lien. Si le lien contient .mid, il pointe vers un MIDI et on le télécharge avec urlretrieve() dans save_dir. Enfin, on incrémente songNumber de 10 (10 morceaux par page) et on répète jusqu’à une page contenant moins de 10 liens.

!pip install bs4
from urllib.request import urlopen, urlretrieve
from bs4 import BeautifulSoup
import time

# Define save directory.
save_dir = '../guitar/'

# Define URL components
url0 = 'https://www.mutopiaproject.org/cgibin/make-table.cgi?startat='
url1 = '&searchingfor=&Composer=&Instrument=Guitar&Style=&collection=&id=&solo=&recent=&timelength=&timeunit=&lilyversion=&preview='

# Set initial values
songNumber = 0
linkCount = 10

# Locate and download each MIDI file
while linkCount > 0:
    url = url0 + str(songNumber) + url1
    html = urlopen(url)
    soup = BeautifulSoup(html.read())
    links = soup.find_all('a')
    linkCount = 0
    for link in links:
        href = link['href']
        if href.find('.mid') >= 0:
            linkCount = linkCount + 1
            urlretrieve(href, save_dir+href)
    songNumber += 10
    time.sleep(10.0)

Pour en savoir plus sur le scraping, voir le cours Web Scraping with Python de DataCamp et le tutoriel Web Scraping using Python.

Sujets
Python
Apprentissage automatique

Découvrez Python et le machine learning

Cours

Introduction à TensorFlow en Python

4 h
56.5K
Apprenez les principes fondamentaux des réseaux neuronaux et comment créer des modèles d'apprentissage profond à l'aide de TensorFlow.
Afficher les détailsRight Arrow
Commencer Le Cours
Voir plusRight Arrow