Ir al contenido principal

Using TensorFlow 2.0 to Compose Music Tutorial

En este tutorial, aprenderás a entrenar modelos generativos para componer música en TensorFlow 2.0.
Actualizado 17 sept 2026  · 15 min leer

Explorar con IA

ChatGPTClaudePerplexity

introduction to tensorflow in python banner

Este tutorial se ha desarrollado con TensorFlow 2.0 en Python y la API de alto nivel Keras, que gana protagonismo en TensorFlow 2.0. Si quieres profundizar en TensorFlow 2.0, echa un vistazo a Introduction to TensorFlow in Python en DataCamp. Para una revisión exhaustiva de la literatura sobre deep learning aplicado a música, consulta Briot, Hadjerest y Pachet (2019), a los que haremos referencia a lo largo del tutorial.

Modelos generativos

Los modelos de aprendizaje automático supervisado pueden dividirse en dos categorías: modelos discriminativos y modelos generativos. Los modelos discriminativos identifican una frontera de decisión y producen una clasificación. Los modelos generativos crean nuevas instancias de una clase.

Un modelo discriminativo de música podría clasificar canciones por género. Un modelo generativo, en cambio, podría componer canciones de un género concreto. En este tutorial, usaremos modelos generativos para componer música.

Discriminativos vs. generativos
This figure depicts discriminative and generative models of music.

Representación musical

Antes de definir y entrenar un modelo generativo, necesitamos recopilar un conjunto de datos. En música, los datos pueden representarse en forma continua o discreta. La forma continua más común es la señal de audio, normalmente en un archivo WAV. No usaremos formas continuas en este tutorial, pero puedes leer más en el apéndice. Entre las formas discretas habituales están los archivos MIDI (Musical Instrument Digital Interface), el pianoroll y el texto. Nos centraremos en archivos MIDI y extraeremos dos tipos de objetos simbólicos: notas y acordes.

Notas

Una nota es una representación simbólica de un sonido. Aquí, describiremos una nota por su altura y duración. La altura de una nota se relaciona con la frecuencia de oscilación de su onda sonora, medida en hercios (Hz). Cuanto mayor es la altura, más oscilaciones por segundo tiene la onda. La duración es el tiempo durante el que suena.

Los archivos MIDI representan la altura con un entero entre 0 y 127. Las notas también pueden representarse con una letra y un número de octava. Dentro de una misma octava, las alturas se ordenan de menor a mayor frecuencia así:

  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

La octava se indica con un subíndice, como el 4 en A4 o el 7 en C7. Una octava más alta implica una frecuencia mayor. Si tomamos una altura arbitraria Xi, la altura Xi+1, exactamente una octava por encima, corresponde a una onda con el doble de frecuencia que Xi.

Además de la altura, también usaremos la duración de la nota. La duración es un valor relativo, normalizado por la redonda. La nota más larga es la "larga", ocho veces una redonda. La más corta es la doscientos cincuenta y seis avas, que dura 1/256 de una redonda.

Acordes

Un acorde es la combinación de dos o más notas que suenan a la vez en el mismo instrumento. Si analizamos música monofónica —interpretada en un único instrumento— podemos identificar acordes asumiendo que todas las notas que suenan exactamente al mismo tiempo pertenecen al mismo acorde. Esta suposición no vale para música polifónica, en la que suenan dos o más instrumentos simultáneamente.

En el bloque de código siguiente instalamos music21 e importamos el módulo converter, que utilizaremos para analizar archivos MIDI. Cargaremos y analizaremos una pieza para guitarra clásica de Mauro Giuliani. Luego aplicaremos el método .chordify(), que reconstruye la secuencia de acordes de la partitura asumiendo que las notas simultáneas pertenecen al mismo acorde.

!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())

Tras construir la secuencia de acordes del archivo MIDI, podemos mostrarla como partitura con .show(). Para ello necesitarás un programa que cree y muestre partituras, como MuseScore, disponible gratis.

Partitura generada desde un MIDI
This figure shows a MIDI file displayed as sheet music.

Si lo prefieres, también puedes examinar y manipular los elementos subyacentes como texto usando .show('text'). Al inicio de cada línea verás el offset entre corchetes, que indica cuántos segundos han transcurrido cuando aparece el elemento. Fíjate en que solo uno de los elementos en el segundo 0 es un acorde. El resto son metadatos: instrumento, tempo, tonalidad y compás. Más adelante usaremos la tonalidad, pero por ahora puedes ignorarlos.

 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>

Tras los metadatos, los elementos restantes son acordes ordenados según aparecen en la canción. Por ejemplo, <music21.chord.Chord E3 G3> es el acorde que resulta de tocar las notas E3 y G3, y <music21.chord.Chord A3> es la nota A3 aislada.

En algunos modelos usaremos tanto el acorde como su duración, que mide el tiempo que suena. Podemos obtener esta información accediendo a .elements y al atributo .duration de un acorde cualquiera, como se muestra a continuación.

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

Codificación de notas y acordes

A lo largo del tutorial trabajaremos con representaciones discretas de la música descritas por secuencias de notas, acordes y sus duraciones. Recuerda que los acordes son combinaciones de notas y que el formato MIDI permite 128 notas individuales. Si ampliamos el conjunto de características para incluir todos los acordes de dos notas, añadimos 128!/[(128-2)!*2!], es decir, 8128 elementos. Añadir acordes de tres notas suma 341.376 más, y de cuatro notas, otros 10.668.000.

El ejemplo anterior contiene notas y acordes de dos, tres y cuatro notas. Si usamos one-hot encoding e incluimos todas las características, tendremos que predecir sobre vectores de 11.017.632 dimensiones. Por tanto, conviene pensar bien la codificación en problemas de generación musical. También merece la pena considerar la transposición de tonalidad o la data augmentation, que tratamos en el apéndice.

En cuanto a la codificación, Briot, Hadjerest y Pachet (2019) analizan cuatro opciones habituales en modelos generativos de música:

  1. One-hot encoding
  2. Many-hot encoding
  3. Multi-one-hot encoding
  4. Multi-many-one-hot encoding

El one-hot representa símbolos musicales (notas y acordes) como vectores dispersos con un 1 en la posición de la nota o acorde concreto. El many-hot representa acordes —formados por varias notas— poniendo un 1 en la posición de cada nota.

En el diagrama siguiente comparamos many-hot y one-hot para el caso de notas y acordes de dos notas. Con many-hot, C4 y D4 se representan con vectores de 128 elementos. Ambos tienen ceros en todas las posiciones salvo en C4 y D4, que contienen unos. El acorde C4+D4, resultado de tocar ambas simultáneamente, se construye poniendo unos en ambas posiciones.

Codificación de notas y acordes
This diagram illustrates the difference between one-hot and many-hot encoding.

La representación one-hot de notas sueltas y acordes de dos notas consta de vectores de 8256 elementos. En cada caso el vector es disperso y contiene un 1 en la posición del símbolo único. Observa que el many-hot requiere un modelo multietiqueta, ya que puede haber varias clases por salida. A cambio, reduce sustancialmente el tamaño de los vectores de entrada y salida.

La mayoría de trabajos siguen usando one-hot. No obstante, Briot, Hadjerest y Pachet (2019) señalan varios modelos que emplean many-hot, como RBMc, RNN-RBM y C-RBM, todos basados en arquitecturas RBM.

Las codificaciones multi-one-hot y multi-many-hot generalizan one-hot y many-hot al caso de música polifónica —varios instrumentos a la vez—. Aquí nos limitaremos a música monofónica y no las trataremos más.

Recopilación y preparación de datos

El siguiente paso es reunir un dataset para entrenar un modelo generativo. Usaremos datos del Proyecto Mutopia, que contiene 2124 piezas en formato MIDI de dominio público. Como el sitio está organizado por instrumentos, podemos escribir un script sencillo en Python para extraer todas las piezas de guitarra, con las que entrenaremos los modelos de este tutorial. Para un resumen del proceso, consulta el apéndice.

Supongamos que hemos guardado todos los archivos .mid de la sección de guitarra en un directorio, save_dir. El siguiente paso es cargar cada archivo .mid en music21 como un objeto 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)

Como parte de la reducción de dimensionalidad, nos quedaremos con las obras de Mauro Giuliani. Podemos modificar el bloque anterior así.

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

Antes de entrenar necesitamos hacer cuatro cosas más:

  1. Eliminar música polifónica y aplicar .chordify() a los stream restantes.
  2. Extraer la secuencia de notas, acordes y duraciones.
  3. Construir un mapeo entre notas, acordes y duraciones e enteros (ida y vuelta).
  4. Dividir los streams en secuencias de longitud fija para usarlas como entrada de los modelos.

Empecemos eliminando la música polifónica. Podemos particionar cada stream por instrumento y comprobar si hay una sola partición —es decir, si solo suena un instrumento—. Lo hacemos en el siguiente bloque.

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]

Ten en cuenta que, si aplicamos .chordify() —como en la figura— sin eliminar la polifonía, mezclaremos por error notas de distintos instrumentos en un mismo acorde.

Convertir notas en acordes
This figure shows notes being converted to chords.

Ahora extraeremos notas, acordes y duraciones de cada stream. Primero definimos tres listas vacías: originalChords, originalDurations, originalKeys. Recorremos la lista de streams. Para cada uno, identificamos y almacenamos todas las notas, acordes y duraciones, además de la tonalidad de la pieza.

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

Seguimos reduciendo dimensionalidad quedándonos solo con música en Do mayor, que es la tonalidad más común en nuestro conjunto. A partir de aquí, usaremos la palabra "acorde" para referirnos tanto a acordes como a notas.

# 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')]

El siguiente paso es identificar el conjunto único de acordes y duraciones y construir diccionarios que los mapeen a enteros. Si imprimimos el número de notas y acordes únicos, veremos que las medidas de reducción han bajado la cifra a un manejable 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

Una vez entrenado el modelo y generadas predicciones, querremos deshacer el mapeo de enteros a notas, acordes y duraciones. Invertimos los diccionarios chordToInt y durationToInt a continuación.

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

Por último, definimos las secuencias de entrenamiento, compuestas por 32 notas y acordes consecutivos con sus duraciones. Empezaremos con modelos tipo autoencoder, donde características y objetivos coinciden.

# 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])

Generación musical

En esta sección usaremos el dataset preparado para generar música. Veremos tres modelos de generación, empezando por el más simple:

  1. Autoencoder
  2. Autoencoder variacional (VAE)
  3. Modelo LSTM (Long Short-Term Memory)

Autoencoders

Un autoencoder consta de dos redes apiladas verticalmente y unidas por un vector latente. Las entradas pasan primero por un encoder, normalmente con una o más capas densas. La última capa del encoder es el vector latente, un cuello de botella que obliga a comprimir en pocas características latentes lo extraído por el encoder.

El vector latente conecta con un decoder, que descomprime los datos. El número de nodos de cada capa del decoder va aumentando. La capa de salida final tiene las mismas dimensiones que la capa de entrada del encoder.

A diferencia de los modelos discriminativos, que buscan una frontera de decisión, los autoencoders se entrenan para reconstruir los datos de entrada —en este caso, una canción— lo más fielmente posible, sujeto al tamaño del vector latente. Por eso, los autoencoders usan las entradas como objetivo.

Usaremos un autoencoder para nuestro primer modelo generativo. Para simplificar al máximo, adoptaremos la arquitectura del modelo MiniBach presentada en Briot, Hadjerest y Pachet (2019) como simplificación de DeepBach. Otro ejemplo de autoencoder para generar música es DeepHear.

Las entradas, tal y como las hemos construido, son vectores one-hot que representan notas y acordes. Ignoraremos las duraciones por ahora. Siguiendo MiniBach, empezaremos aplanando el vector de entrada.

# 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)

Definimos ahora el número de muestras, nSamples, el número de acordes y notas, nChords, y el tamaño de la entrada, inputDim. Fijamos las dimensiones latentes, latentDim, en 2. Un espacio latente tan pequeño reduce el rendimiento, pero facilita visualizarlo.

# 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

Definimos la arquitectura empezando por la capa de entrada del encoder, encoderInput, y la de entrada del decoder, latent. Observa que latent también es la salida del encoder. A continuación, una capa densa, encoded, que conecta entradas con el vector latente, y otra densa, decoded, que conecta el vector latente con la salida.

Por último, definimos el modelo decoder y el autoencoder. Recuerda que .Model() recibe inputs y outputs. Para decoder, pasamos latent como entrada y decoded como salida. Para el autoencoder completo, usamos encoderInput como entrada y pasamos el decoder evaluado sobre la salida del encoder, encoded, como entrada.

# 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))

La arquitectura de un autoencoder genérico se muestra a continuación. La mitad superior es el encoder y la inferior el decoder, conectados por el vector latente.

Arquitectura de un autoencoder
This figure shows the architecture for an autoencoder.

Definidos decoder y autoencoder, solo queda compilar y entrenar con .fit(). Dos detalles importantes:

  1. Las entradas serán 0s y 1s, y las salidas valores reales entre 0 y 1. Podemos usar binary_crossentropy.
  2. Las entradas y los objetivos son los mismos: trainChordsFlat.
# Compile autoencoder model
autoencoder.compile(loss = 'binary_crossentropy', learning_rate = 0.01, optimizer='rmsprop')

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

El último paso es generar música. Como fijamos la longitud de secuencia en 32, el autoencoder tomará 32 acordes y notas como entrada y producirá una "canción" de 32 acordes y notas. Está entrenado para generar salidas muy similares a la entrada; sin embargo, nuestro objetivo es crear música nueva. Para ello pasaremos un vector latente aleatorio al decoder, definido como subred del autoencoder.

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

Hemos remodelado la salida del autoencoder con las mismas dimensiones que la entrada original. Luego hacemos argmax() sobre la primera dimensión. Esto devuelve un entero que corresponde a un acorde, que identificamos a continuación.

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

Por último, creamos un objeto stream con music21, fijamos el instrumento a guitarra, añadimos la secuencia de acordes generada por el modelo y exportamos todo como archivo 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')

El gif siguiente muestra parte del entrenamiento de un autoencoder. La partitura generada en cada paso es la reconstrucción asociada a un estado latente concreto. Cada fotograma representa 5 épocas.

Autoencoder: evolución de notas y acordes
This figure shows shifts in the latent space over time.

También podemos visualizar el movimiento en el espacio latente durante el entrenamiento, como en el gif siguiente. Observa que el espacio latente es estrecho y deriva entre épocas. Esto dificulta muestrear un estado latente aleatorio de calidad y será más problemático con espacios latentes de mayor dimensión. En la siguiente sección veremos cómo solucionarlo con un autoencoder variacional (VAE).

Autoencoder: evolución del espacio latente
This figure shows shifts in the latent space over time.

Por último, podemos escuchar la salida del autoencoder. Primero, la secuencia de acordes de la canción original, escogida al azar del conjunto de entrenamiento. Después, la secuencia generada por el autoencoder usando el vector latente asociado a la secuencia original.

Ejemplo: canción original
Ejemplo: canción generada

Autoencoder variacional

Aunque los autoencoders funcionan bien para tareas de eliminación de ruido, compresión y descompresión, no destacan como modelos generativos. Los autoencoders variacionales (VAE), presentados por Kingma y Welling (2017), corrigen esas debilidades y son uno de los métodos más usados para generar música. Briot, Hadjerest y Pachet (2019) identifican cuatro sistemas basados en VAE: MusicVAE, VRAE, VRASH y GSLR-VAE. Mejoran a los autoencoders en dos aspectos:

  1. En lugar de mapear cada entrada a un punto del espacio latente, un VAE la mapea a una distribución normal caracterizada por media y varianza.
  2. En vez de permitir cualquier media y varianza en el espacio latente, un VAE fuerza a que la media y el logaritmo natural de la varianza estén cerca de 0.

Podemos introducir el primer cambio sustituyendo el vector latente por tres capas:

  1. Una capa de medias
  2. Una capa de (log) varianzas
  3. Una capa de muestreo

Cada entrada se mapea a una media y al logaritmo de la varianza, suficientes para caracterizar una normal. El modelo extrae puntos aleatorios de esa distribución en la capa de muestreo.

El bloque siguiente define una función para crear una capa de muestreo. Recibe media y log varianza. Luego usa tensorflow.random para extraer un tensor epsilon de una normal estándar (media y log varianza 0). A continuación escalamos epsilon por la desviación estándar de cada distribución —exp(logVar/2)— y sumamos la media.

# 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

Ahora modificamos el autoencoder para incorporar las capas de mean, logVar y sampling. Observa que la capa encoded ahora tiene dos entradas y el encoder tres salidas.

# 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))

Como en el autoencoder, el estado latente es la salida de encoded. La diferencia clave: en lugar de mapear cada entrada a un único estado, usamos la capa de sampling para muestrear valores aleatorios de una normal con parámetros mean y logVar. Así, la misma entrada se asocia a una distribución de estados latentes.

El paso final es ajustar la función de pérdida para disciplinar los valores de mean y logVar. Calcularemos la divergencia de Kullback-Leibler (KL) entre cada distribución definida por mean y logVar y la normal estándar (media y log varianza 0). Cuanto más se alejen de 0, mayor será el término KL.

¿Por qué forzar que cada distribución sea cercana a una normal estándar? Porque queremos muestrear estados latentes y usarlos para generar música. Si aplicamos la penalización KL, podremos usar muestreos independientes de una normal estándar para obtener estados latentes con alta probabilidad de generar piezas de calidad.

En el bloque siguiente mostramos cómo modificar la pérdida para incluir el término KL. El primer componente es la pérdida de entropía cruzada binaria entre entradas y salidas del vae (la "pérdida de reconstrucción"). El término KL es la KL media entre las distribuciones latentes y la normal estándar. Sumamos ambos para obtener la pérdida total, vaeLoss, la añadimos al modelo, compilamos y entrenamos.

# 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)

En la sección anterior vimos que el espacio latente del autoencoder no estaba bien comportado. Parte del objetivo de un VAE es resolver este defecto en contextos generativos. La figura siguiente muestra el espacio latente del VAE: hay dispersión en ambas características, están centradas en 0 y no derivan entre épocas. Esto facilita muestrear estados latentes aleatorios de calidad.

VAE: evolución del espacio latente
This figure shows shifts in the latent space over time.

Pasamos a la generación. Muestreamos un vector latente de una normal estándar, lo pasamos al decoder, remodelamos la salida y tomamos el argmax de cada columna. Esto devuelve una secuencia de 32 enteros que corresponden a notas y acordes, que identificamos con intToChord. Después creamos un stream, añadimos guitarra como instrumento, incorporamos la secuencia y exportamos a 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')

Las tres canciones siguientes han sido generadas por el modelo. Las dos primeras se basan en estados latentes asociados a secuencias del conjunto de entrenamiento. La última es una combinación lineal de los estados latentes de la canción n.º 1 y la n.º 2. Como el VAE fuerza un espacio latente bien comportado, es razonable esperar que una combinación lineal también sea de calidad.

Ejemplo: canción generada n.º 1
Ejemplo: canción generada n.º 2
Ejemplo: combinación lineal de las canciones n.º 1 y n.º 2

Una cosa que se aprecia en la música generada con autoencoders y VAEs es que carece de una melodía marcada. Podemos mejorar añadiendo más capas y ampliando el conjunto de entrenamiento. Otra opción es usar un modelo diseñado para secuencias, como haremos en la siguiente sección.

Long Short-Term Memory (LSTM)

El último modelo que veremos es LSTM, un tipo de red neuronal recurrente (RNN) modificada para evitar el problema del gradiente que se desvanece. Briot, Hadjerest y Pachet (2019) concluyen que los modelos recurrentes son los más usados para generar música. Identifican más de 20 sistemas basados en RNN o LSTM, como BachBot, DeepBach, Performance-RNN y Hexahedria.

Usar LSTM ofrece dos ventajas frente a un autoencoder:

  1. Está diseñado para procesar datos secuenciales.
  2. Puede generar secuencias musicales de cualquier longitud.

A diferencia de autoencoders y VAEs, que usan la entrada como objetivo, los modelos generativos con LSTM se entrenan para predecir la siguiente nota o acorde de la secuencia. Por eso reconstruiremos los train y target, como verás abajo. Añadiremos también duraciones, ya que con LSTM necesitamos menos parámetros.

# 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])

Observa que trainChords y trainDurations se definen igual que antes. Solo hemos añadido targetChords y targetDurations, que contienen el acorde y la duración que siguen a la secuencia de entrenamiento. A continuación fijamos el número de muestras, acordes y duraciones, así como la dimensión de entrada. También usaremos capas de embeddings separadas para acordes y duraciones.

# 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

Definimos ahora la arquitectura. Usaremos un modelo con dos entradas y dos salidas para predecir acordes y duraciones. Primero las entradas, chordInput y durationInput, que pasan a capas de embedding, chordEmbedding y durationEmbedding. Son tablas que asocian un vector de 64 elementos a cada acorde o duración. Los embeddings permiten sustituir vectores dispersos one-hot por representaciones densas donde notas y duraciones relacionadas tienen valores cercanos.

Las salidas de ambas capas de embedding se concatenan en mergeLayer. Luego pasamos su salida por dos capas LSTM. En la primera fijamos return_sequences en True. Si lo dejamos en False (valor por defecto), las celdas LSTM devuelven solo el estado final, no los intermedios. Trabajos recientes muestran que aprovechar estados intermedios puede mejorar el rendimiento, a menudo con capas de atención. Finalmente, pasamos la salida LSTM a una densa y de ahí a dos salidas: chordOutput y durationOutput. Definimos el modelo funcional de keras con ambas entradas y salidas.

# 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])

Con el modelo definido, compilamos y entrenamos. Hemos intentado mantenerlo simple, pero en muchos casos tendrá sentido añadir regularización y más capas densas y LSTM. Observa que el modelo tiene dos entradas y dos salidas, como se muestra en el diagrama. Puedes aprender más sobre modelos con múltiples entradas y salidas en Introduction to TensorFlow in Python.

Arquitectura del modelo 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)

Una vez terminado el entrenamiento, podemos generar nuevas canciones con el LSTM. Alimentaremos una secuencia inicial de 32 acordes y duraciones para hacer la primera predicción. Luego añadiremos esas predicciones a las series de acordes y duraciones, lo que nos permitirá predecir de nuevo sobre las últimas 32 posiciones. Una ventaja del LSTM es que podemos iterar indefinidamente y generar canciones de longitud arbitraria.

# 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

El paso final es exportar la música generada a MIDI con music21. El código es casi idéntico al usado con autoencoders, pero con una diferencia importante: ahora añadimos tanto acordes como duraciones al 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')

Con los autoencoders y VAEs nos costaba generar melodías y solo podíamos producir canciones de longitud fija. Con un LSTM podemos generar secuencias de longitud arbitraria iterando predicciones, como hemos hecho arriba, y nos resultará más fácil obtener mejores melodías. Lo verás en los ejemplos siguientes, cada uno inicializado con una secuencia de acordes y duraciones tomada del entrenamiento.

Ejemplo: canción generada n.º 1
Ejemplo: canción generada n.º 2
Ejemplo: canción generada n.º 3

   introduction to tensorflow in python course banner

Cuestiones adicionales

Datos

Uno de los grandes retos para desarrollar modelos de machine learning en música es la disponibilidad de datasets, ya que la música rara vez está disponible para descarga libre y legal. Esto ha impedido que exista un conjunto de práctica estándar, como MNIST en clasificación de imágenes. Aun así, hay algunos proyectos que ofrecen audio crudo, notación simbólica o características derivadas para un gran número de canciones. A continuación listamos algunos.

  1. Proyecto Mutopia. Archivo de música de dominio público y creative commons en formatos MIDI, PDF y LilyPond. Contiene más de 2000 piezas y está organizado por instrumento, compositor, estilo y colección.
  2. The Million Song Dataset. Incluye características de audio y metadatos de más de 300 GB de música popular, pero no pistas de audio.
  3. Free Music Archive. Contiene audio, etiquetas de género y características de más de 100.000 pistas disponibles libre y legalmente.

Muestreo vs determinismo

Para generar música con LSTM utilizamos un enfoque determinista para elegir acordes y duraciones. El modelo predice probabilidades para cada uno de los 267 acordes y 9 duraciones, y elegimos los de mayor probabilidad. Del mismo modo, en los autoencoders y VAEs seleccionamos los acordes con mayor valor de salida.

Una alternativa es muestrear de la distribución de salidas en lugar de escoger los máximos. Así el proceso de generación se vuelve estocástico. Esto implica que modelos autoencoder o VAE con el mismo vector latente generarán canciones distintas cada vez. Igualmente, la misma secuencia de entrada a un LSTM podrá generar canciones diferentes.

El bloque siguiente muestra cómo pasar a una generación estocástica modificando la función predictChords(). Hemos sustituido argmax() por un muestreo aleatorio guiado por las probabilidades.

# 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)

Para una discusión extendida sobre enfoques estocásticos y deterministas en generación musical, consulta la sección 6.6 de Briot, Hadjerest y Pachet (2019).

Embeddings

Al entrenar el LSTM usamos una capa de embeddings, que mapea cada acorde y duración a un vector denso de 64 elementos. Esto nos evita usar vectores dispersos y de alta dimensión como entrada, y nos permite capturar relaciones entre acordes.

En muchos casos no tendrá sentido entrenar una capa de embeddings desde cero, porque a menudo no dispondremos de datasets suficientemente grandes. Usar embeddings preentrenados o obtenidos de manera no supervisada —como hacemos a menudo en texto— permite entrenar el modelo cuando de otro modo sería inviable.

Consulta Chuan, Agres y Herremans (2018) para un repaso de cómo construir embeddings con aprendizaje no supervisado.

Redes generativas antagónicas

Hemos hablado de autoencoders, VAEs y LSTM para generar música. Otra opción con buenos resultados en otros dominios son las redes generativas antagónicas (GAN). Una GAN combina una red discriminativa con una generativa. La red generativa crea muestras —como las secuencias de acordes que generaron nuestros modelos—. Estas muestras se combinan en un conjunto de entrenamiento junto con muestras reales, es decir, secuencias de canciones verdaderas. El modelo discriminativo intenta clasificar si una muestra es real o generada. El generador intenta engañar al discriminador haciendo que clasifique sus muestras como reales. Se entrena hasta alcanzar un equilibrio estable en el que ninguno puede mejorar sin que el otro se adapte.

Si te interesa aplicar GANs a la generación musical, estos recursos pueden ayudarte:

  1. MuseGAN es un proyecto orientado a la generación de música polifónica con GANs. Incluye modelos preentrenados y 174.154 canciones limpias y preparadas en formato pianoroll.

  2. Magenta es un proyecto open source centrado en modelado generativo para tareas creativas, incluida la música. Puedes explorar sus herramientas en este notebook de Colab.

Recursos

Si quieres aprender más sobre TensorFlow 2.0, visita Introduction to TensorFlow in Python en DataCamp. Para una revisión reciente y completa de métodos de generación musical con deep learning, consulta Briot, Hadjerest y Pachet (2019). Para más tutoriales sobre generación musical con modelos de deep learning, mira 1, 2, 3, 4 y 5, que ofrece un tratamiento completo de los modelos generativos.

Apéndice

Representación continua

Los archivos WAV son la forma continua más común porque contienen la señal de audio sin comprimir. Esto difiere de, por ejemplo, mp3, que comprimen el tamaño descartando información de la señal.

El bloque siguiente muestra cómo extraer la tasa de muestreo, rate, y la señal de audio, signal, de un WAV con una pieza de Mozart. La tasa de muestreo es el número de veces por segundo que se muestrea la señal. En nuestro caso, 44100.

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

La señal de audio, signal, es un tensor S x C, donde S es el número de muestras y C el de canales. Por simplicidad, consideramos C = 1. El gráfico siguiente muestra un intervalo de tres segundos de signal.

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

En general, entrenar con señales de audio crudas es bastante más complejo que con representaciones discretas. Una revisión reciente de métodos de deep learning para generación musical examinó 32 enfoques; solo tres usaban representaciones continuas:

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

Transposición de tonalidad

La transposición puede usarse para reducir dimensionalidad o para aumentar datos. Consiste en desplazar la altura de cada nota un intervalo fijo. Si una canción contiene C3 seguida de D3 y transponemos dos alturas hacia arriba, C3 pasa a D3 y D3 a E3. Para modelos que usan transposición, ver Lim, Rhyu y Lee (2018) y Sturm et al. (2016).

Si usamos transposición para reducir dimensionalidad, el objetivo es desplazar notas y acordes para minimizar el número total de símbolos únicos. Como la música suele escribirse en torno a grupos de alturas —tonalidades— que suenan bien juntas, podemos trasladar todas las canciones a la misma tonalidad.

Esta vez importaremos tres submódulos de music21:

  1. converter, para cargar un MIDI y convertirlo en stream.
  2. pitch, para crear la altura objetivo.
  3. interval, para calcular la distancia entre la tonalidad original y la objetivo.
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

Primero aplicamos .analyze('key') a score para identificar la tonalidad, que es Do mayor. Luego calculamos el intervalo entre Do mayor y nuestra tonalidad objetivo, Fa mayor. Accedemos a la altura de key con .tonic y creamos la altura objetivo con pitch.Pitch('F'). Asignamos el resultado a keyInterval, que indica la distancia entre tonalidades.

Finalmente, transponemos con .transpose() pasando keyInterval. Imprimir la nueva tonalidad confirma que es Fa mayor.

Transposición de octava

Podemos ir más allá y transponer también la octava hacia arriba o abajo. En lugar de transponer Do mayor a Fa mayor, podríamos transponer Do mayor a Do mayor en otra octava. Si fijamos el mismo rango de octavas para cada canción, forzaremos a que notas y acordes se agrupen, reduciendo la dimensionalidad y simplificando el entrenamiento. En el código anterior bastaría con especificar la octava en la altura, por ejemplo pitch.Pitch('F3').

Invariancia a tonalidad y octava

Una alternativa a reducir dimensionalidad es aumentar datos. En lugar de reducir el espacio de entradas y salidas, ampliamos el dataset modificando los datos existentes. También podemos hacerlo mediante transposición de tonalidad y octava. En vez de llevar todas las canciones a una tonalidad común y agrupar sus notas en octavas adyacentes, podríamos transponer todas las canciones a todas las tonalidades y octavas. Así lograríamos invariancia a tonalidad y octava en el modelo. Consulta Briot, Hadjerest y Pachet (2019) para más detalles.

Recopilación de datos

Recopilaremos los archivos MIDI para entrenar el modelo desde el Proyecto Mutopia. Antes de hacer scraping, revisaremos el archivo robots en https://www.mutopiaproject.org/robots.txt para respetar sus indicaciones. En el momento de escribir este tutorial, el archivo contenía el texto del bloque siguiente. User-agent: * indica que la regla se aplica a todos los usuarios. Disallow:, seguido de nada, significa que no hay restricciones al scraping.

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

Importaremos urlopen y urlretrieve de urllib.request para enviar peticiones a cada página de la sección de guitarra y descargar cada MIDI enlazado. Usaremos BeautifulSoup para parsear el HTML devuelto por urlopen y localizar enlaces a MIDIs. Por último, el módulo time para pausar 10 segundos entre descargas y no sobrecargar el sitio.

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

Cada iteración del bucle principal comprueba si el número de enlaces en la página, linkCount, es mayor que 0. Si lo es, construye la URL de la siguiente página, url, combinando url0, url1 y el número de canción, songNumber. Luego abre la URL, parsea el HTML, encuentra todos los enlaces, resetea linkCount a 0 y recorre cada enlace. Si contiene la subcadena .mid, apunta a un archivo MIDI y se descarga con urlretrieve() en el directorio save_dir. Finalmente incrementamos songNumber en 10, ya que cada página contiene 10 canciones. Repetimos hasta encontrar una página con menos de 10 enlaces.

!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)

Si quieres aprender más sobre scraping, echa un vistazo al curso de DataCamp Web Scraping with Python y al tutorial Web Scraping using Python.

Temas
Python
Aprendizaje automático

Aprende más sobre Python y machine learning

Curso

Introducción a TensorFlow en Python

4 h
56.5K
Aprende los fundamentos de las redes neuronales y cómo construir modelos de aprendizaje profundo con TensorFlow.
Ver detallesRight Arrow
Iniciar Curso
Ver másRight Arrow
Relacionado
Data Augmentation Header

Tutorial

Guía completa para el aumento de datos

Aprende sobre técnicas, aplicaciones y herramientas de aumento de datos con un tutorial de TensorFlow y Keras.
Abid Ali Awan's photo

Abid Ali Awan

15 min

Tutorial

Tutorial de Generación de nubes de palabras en Python

Aprende a realizar Análisis exploratorios de datos para el Procesamiento del lenguaje natural utilizando WordCloud en Python.
Duong Vu's photo

Duong Vu

11 min

Tutorial

Aprendizaje automático de datos categóricos con el tutorial de Python

Aprenda los trucos más comunes para manejar datos categóricos y preprocesarlos para construir modelos de aprendizaje automático.
Moez Ali's photo

Moez Ali

14 min

Tutorial

Tutorial del Modelo de Transformador en PyTorch: De la teoría al código

Aprende a construir un modelo Transformer utilizando PyTorch, una potente herramienta del aprendizaje automático moderno.
Arjun Sarkar's photo

Arjun Sarkar

15 min

Tutorial

Introducción a Q-learning: tutorial para principiantes

Conoce el algoritmo de aprendizaje por refuerzo sin modelo más popular con un tutorial en Python.
Abid Ali Awan's photo

Abid Ali Awan

11 min

Tutorial

Tutorial del Optimizador Adam: Intuición e implementación en Python

Comprender y aplicar el optimizador Adam en Python. Aprende la intuición, las matemáticas y las aplicaciones prácticas del aprendizaje automático con PyTorch
Ver MásVer Más