Pular para o conteúdo principal

Tutorial: compondo música com TensorFlow 2.0

Neste tutorial, você vai aprender a treinar modelos generativos para compor música no TensorFlow 2.0.
Atualizado 17 de set. de 2026  · 15 min lido

Explorar com IA

ChatGPTClaudePerplexity

introduction to tensorflow in python banner

Este tutorial foi desenvolvido com base no TensorFlow 2.0 em Python, usando a API de alto nível Keras, que ganhou ainda mais destaque no TensorFlow 2.0. Se você quer se aprofundar em TensorFlow 2.0, confira o curso Introduction to TensorFlow in Python na DataCamp. Para uma revisão abrangente da literatura de deep learning para música, veja Briot, Hadjerest e Pachet (2019), referência recorrente ao longo deste tutorial.

Modelos generativos

Modelos de machine learning supervisionado podem ser divididos em duas categorias: modelos discriminativos e modelos generativos. Modelos discriminativos aprendem uma fronteira de decisão e produzem uma classificação correspondente. Modelos generativos criam novas instâncias de uma classe.

Um modelo discriminativo de música pode classificar músicas em diferentes gêneros. Um modelo generativo pode compor músicas de um gênero específico. Neste tutorial, vamos usar modelos generativos para compor música.

Discriminativo vs. generativo
This figure depicts discriminative and generative models of music.

Representação musical

Antes de definir e treinar um modelo generativo, precisamos montar um dataset. Em música, os dados podem ser representados de forma contínua ou discreta. A forma contínua mais comum é o sinal de áudio, geralmente armazenado como arquivo WAV. Não vamos usar formas contínuas neste tutorial, mas você pode ler mais sobre elas no apêndice. Formas discretas comuns incluem arquivos MIDI (Musical Instrument Digital Interface), pianoroll e texto. Vamos focar em arquivos MIDI e extrair dois tipos de objetos simbólicos: notas e acordes.

Notas

Uma nota é uma representação simbólica de um som. Para nossos propósitos, podemos descrevê-la por altura e duração. A altura (pitch) de uma nota está relacionada à frequência de oscilação da onda sonora, medida em hertz (Hz). Notas mais agudas têm ondas com mais oscilações por segundo. A duração é o tempo durante o qual a nota é tocada.

Arquivos MIDI representam a altura de uma nota com um inteiro entre 0 e 127. Notas também podem ser representadas por letra e número da oitava. Na mesma oitava, as alturas são ordenadas da menor para a maior frequência assim:

  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

A oitava é indicada por um subscrito, como o 4 em A4 ou o 7 em C7. Oitavas mais altas correspondem a frequências mais altas. Se tomarmos uma altura arbitrária Xi, então Xi+1, exatamente uma oitava acima, representa uma onda com o dobro da frequência de Xi.

Além da altura, também vamos usar a duração da nota. A duração é relativa e normalizada pelo comprimento de uma semibreve. A nota mais longa é a "máxima", com oito vezes a duração de uma semibreve. A mais curta é a ducentésima quinquagésima sexta (1/256 da semibreve).

Acordes

Um acorde é a combinação de duas ou mais notas tocadas simultaneamente no mesmo instrumento. Em música monofônica — ou seja, tocada por um único instrumento — podemos identificar acordes assumindo que todas as notas executadas exatamente no mesmo instante fazem parte do mesmo acorde. Essa suposição não vale para música polifônica, com dois ou mais instrumentos soando ao mesmo tempo.

No bloco de código abaixo, instalamos o music21 e importamos o módulo converter, que usaremos para analisar arquivos MIDI. Vamos carregar e processar uma peça para violão clássico de Mauro Giuliani. Em seguida, aplicamos o método .chordify(), que reconstrói a sequência de acordes da partitura, assumindo que notas tocadas simultaneamente pertencem ao mesmo 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())

Depois de construir a sequência de acordes do arquivo MIDI, podemos exibí-la como partitura com o método .show(). Isso requer um software capaz de criar e exibir partituras, como o MuseScore, disponível gratuitamente.

Partitura gerada a partir de MIDI
This figure shows a MIDI file displayed as sheet music.

Como alternativa, podemos examinar e manipular os elementos subjacentes como texto usando .show('text'). No início de cada linha, vemos o offset entre colchetes, indicando quantos segundos após o início da peça o elemento aparece. Note que apenas um dos elementos no segundo 0 é um acorde. O restante são metadados, incluindo instrumento, andamento, tonalidade e compasso. Vamos usar a tonalidade mais adiante; por ora, você pode ignorar esses elementos.

 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>

Após os metadados, os elementos restantes são acordes, ordenados conforme aparecem na música. Por exemplo, <music21.chord.Chord E3 G3> é o acorde resultante de tocar as notas E3 e G3. Já <music21.chord.Chord A3> é a nota A3 tocada isoladamente.

Em alguns modelos, vamos usar o acorde e sua duração, que mede por quanto tempo ele é tocado. Podemos obter essa informação com o método .elements e o atributo .duration para um acorde arbitrário, como no código abaixo.

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

Codificação de notas e acordes

Neste tutorial, focaremos representações discretas de música descritas por sequências de notas, acordes e durações. Lembre que acordes são combinações de notas e que o formato MIDI permite 128 notas individuais. Se quisermos expandir o conjunto de features para incluir todos os acordes de duas notas, o tamanho aumenta em 128!/[(128-2)!*2!], ou 8128 elementos. Incluir acordes de três notas adiciona 341.376, e de quatro notas, mais 10.668.000.

O exemplo anterior contém notas e acordes de 2, 3 e 4 notas. Se usarmos one-hot encoding e incluirmos todos os recursos, teremos que prever vetores de 11.017.632 dimensões. Portanto, vale pensar com cuidado na escolha da codificação em problemas de geração musical. Também podemos considerar transposição de tonalidade ou aumento de dados (data augmentation), discutidos no apêndice.

Sobre a escolha da codificação, Briot, Hadjerest e Pachet (2019) discutem quatro opções comuns em modelos generativos de música:

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

One-hot encoding representa símbolos musicais, como notas e acordes, como vetores esparsos com um 1 na posição correspondente àquela nota ou acorde. Many-hot encoding representa acordes — compostos por várias notas — colocando 1 em cada posição correspondente às notas do acorde.

No diagrama abaixo, comparamos many-hot e one-hot quando temos notas e acordes de duas notas. Em many-hot, C4 e D4 são vetores com 128 elementos, zeros em todas as posições exceto nas de C4 e D4, que têm 1. O acorde C4+D4, resultado de tocar C4 e D4 simultaneamente, é construído com 1 em ambas as posições.

Codificação de notas e acordes
This diagram illustrates the difference between one-hot and many-hot encoding.

A representação one-hot de notas e acordes de duas notas tem vetores de 8256 elementos. Em cada caso, o vetor é esparso e contém 1 na posição correspondente a uma nota ou acorde único. Note que many-hot exige um modelo multilabel, em que várias classes podem ser previstas para uma mesma saída. Por outro lado, reduz substancialmente o tamanho dos vetores de entrada e saída.

A maioria dos modelos na literatura ainda usa one-hot. Porém, Briot, Hadjerest e Pachet (2019) citam diversos modelos de geração musical que usam many-hot, incluindo os sistemas RBMc, RNN-RBM e C-RBM, todos baseados na arquitetura de restricted Boltzmann machine (RBM).

As codificações multi-one-hot e multi-many-hot generalizam one-hot e many-hot para o caso de música polifônica — múltiplos instrumentos simultâneos. Vamos nos restringir à música monofônica e não aprofundar nessas opções.

Coleta e preparação de dados

O próximo passo é coletar um dataset para treinar um modelo generativo. Vamos usar dados do Mutopia Project, que contém 2124 peças em formato MIDI em domínio público. Como o site é dividido por instrumento, podemos escrever um script simples em Python para baixar todas as peças para violão, que serão nossa base de treino. Um passo a passo está no apêndice.

Vamos supor que salvamos todos os arquivos .mid da seção de violão em um diretório save_dir. O próximo passo é carregar cada .mid no music21 como um 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 da nossa estratégia de redução de dimensionalidade, vamos nos restringir às músicas compostas por Mauro Giuliani. Podemos adaptar o bloco acima assim:

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

Precisamos fazer mais quatro coisas antes de treinar:

  1. Remover música polifônica e aplicar .chordify() aos objetos stream restantes.
  2. Extrair a sequência de notas, acordes e durações.
  3. Criar um mapeamento que traduza notas, acordes e durações para inteiros e vice-versa.
  4. Dividir os streams em sequências de tamanho fixo para usar como entrada dos modelos.

Vamos começar removendo música polifônica. Podemos particionar cada stream por instrumento e verificar se há apenas uma partição — isto é, apenas um instrumento tocando. Fazemos isso no bloco abaixo.

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]

Note que, se aplicarmos .chordify() — ilustrado na figura abaixo — sem remover música polifônica, vamos juntar por engano notas de instrumentos diferentes em um único acorde.

Convertendo notas em acordes
This figure shows notes being converted to chords.

Agora precisamos extrair notas, acordes e durações de cada stream. Primeiro, definimos três listas vazias: originalChords, originalDurations, originalKeys. Vamos iterar pela lista de streams. Para cada stream, identificamos e armazenamos todas as notas, acordes e durações, além da tonalidade da peça.

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

Vamos reduzir ainda mais a dimensionalidade mantendo apenas músicas na tonalidade de Dó maior (C major), a mais comum no nosso conjunto. A partir daqui, usaremos a palavra "acorde" para nos referirmos tanto a acordes quanto 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')]

O próximo passo é identificar o conjunto de acordes e durações únicos. Em seguida, construímos dicionários que os mapeiem para inteiros. Se imprimirmos o número de notas e acordes únicos, veremos que as etapas de redução de dimensionalidade baixaram o total para administráveis 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

Depois de treinar o modelo e gerar previsões, vamos querer mapear os inteiros de volta para notas, acordes e durações. Invertamos os dicionários chordToInt e durationToInt abaixo.

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

Por fim, definimos nossas sequências de treino, compostas por 32 notas e acordes consecutivos e suas durações correspondentes. Primeiro, faremos isso para modelos do tipo autoencoder, onde features e targets são os mesmos.

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

Geração de música

Nesta seção, vamos usar o dataset que preparamos para gerar música. Consideraremos três modelos, começando pelo mais simples:

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

Autoencoders

Um autoencoder tem duas redes empilhadas verticalmente e conectadas por um vetor latente. As entradas passam primeiro por um encoder, geralmente com uma ou mais camadas densas. A última camada do encoder é o vetor latente — um gargalo que comprime as features em um pequeno número de variáveis latentes.

O vetor latente se conecta a um decoder, que faz a "descompressão" dos dados. O número de nós aumenta progressivamente a cada camada do decoder. A camada de saída final tem as mesmas dimensões da camada de entrada do encoder.

Diferentemente de modelos discriminativos, que aprendem uma fronteira de decisão, autoencoders são treinados para reconstruir a entrada — no nosso caso, uma música — o mais fielmente possível, dadas as restrições do tamanho do vetor latente. Por isso, autoencoders usam as próprias entradas como alvo.

Vamos usar um autoencoder para construir nosso primeiro gerador musical. Para simplificar ao máximo, adotaremos a arquitetura do modelo MiniBach introduzida em Briot, Hadjerest e Pachet (2019) como simplificação do DeepBach. Outro exemplo é o DeepHear.

Os dados de entrada, como construímos, são vetores one-hot representando notas e acordes. Vamos ignorar as durações por enquanto. Seguindo o MiniBach, começamos achatando o vetor 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)

Em seguida, definimos o número de amostras, nSamples, o número de acordes e notas, nChords, e o tamanho da entrada, inputDim. Definimos também o número de variáveis latentes, latentDim, como 2. Um latentDim baixo reduz o desempenho, mas facilita a visualização do espaço latente.

# 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

Agora definimos a arquitetura, começando pela camada de entrada do encoder, encoderInput, e a entrada do decoder, latent. Note que a camada latent também é a saída do encoder. Definimos uma camada densa encoded que liga as entradas ao vetor latente e outra densa decoded que liga o latente à saída.

Por fim, definimos o modelo do decoder, decoder, e o autoencoder, autoencoder. O método .Model() recebe inputs e outputs. Para o decoder, passamos o vetor latent como input e a rede decoded como output. Para o autoencoder completo, usamos encoderInput como input e o decoder, avaliado sobre a saída do encoder (encoded), como output.

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

A arquitetura genérica de um autoencoder está na figura abaixo. A metade superior é o encoder; a inferior, o decoder. Eles são conectados pelo vetor latente.

Arquitetura de rede: autoencoder
This figure shows the architecture for an autoencoder.

Com o decoder e o autoencoder definidos, restam compilar e treinar com .fit(). Dois pontos importantes:

  1. As entradas são 0s e 1s, e as saídas previstas serão valores reais entre 0 e 1. Podemos usar binary_crossentropy.
  2. Entradas e alvos são iguais: trainChordsFlat.
# Compile autoencoder model
autoencoder.compile(loss = 'binary_crossentropy', learning_rate = 0.01, optimizer='rmsprop')

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

O último passo é gerar música. Como definimos sequências de 32, o autoencoder recebe 32 acordes/notas e produz uma "música" de comprimento fixo com 32 acordes/notas. O autoencoder é treinado para gerar saídas muito parecidas com a entrada; porém, queremos músicas novas. Para isso, passamos um vetor latente aleatório ao decoder, definido como sub-rede do autoencoder.

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

Reformatamos a saída do autoencoder com as mesmas dimensões da entrada original e aplicamos argmax() no primeiro eixo. Isso retorna um inteiro correspondente a um acorde, que identificamos abaixo.

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

Por fim, criamos um objeto stream com music21, definimos violão como instrumento, anexamos a sequência de acordes gerada e exportamos tudo como 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')

O gif abaixo mostra parte do processo de treino de um autoencoder. A partitura gerada em cada etapa é a reconstrução associada a um estado latente específico. Cada frame representa 5 épocas de treino.

Autoencoder: evolução de notas e acordes
This figure shows shifts in the latent space over time.

Também podemos visualizar o movimento no espaço latente durante o treino, como no gif a seguir. Note que o espaço é estreito e deriva ao longo das épocas. Isso dificulta amostrar um estado latente de boa qualidade — e fica ainda pior com mais dimensões latentes. Na próxima seção, veremos como resolver isso com um variational autoencoder (VAE).

Autoencoder: evolução do espaço latente
This figure shows shifts in the latent space over time.

Por fim, podemos ouvir a saída do nosso autoencoder. Primeiro, a sequência de acordes da música original, escolhida aleatoriamente do conjunto de treino. Em seguida, a sequência gerada pelo autoencoder usando o vetor latente associado à música original.

Exemplo: música original
Exemplo: música gerada

Variational autoencoder

Embora autoencoders sejam ótimos para tarefas de remoção de ruído, compressão e descompressão, eles não se saem tão bem como modelos generativos. Variational autoencoders (VAEs), introduzidos por Kingma e Welling (2017), foram projetados para superar essas limitações e são dos métodos mais usados para geração musical. Briot, Hadjerest e Pachet (2019) listam quatro sistemas de geração musical baseados em VAEs: MusicVAE, VRAE, VRASH e GSLR-VAE. Eles melhoram os autoencoders em dois pontos:

  1. Em vez de mapear cada entrada para um ponto no espaço latente, um VAE mapeia para uma distribuição normal, caracterizada por média e variância.
  2. Em vez de permitir qualquer média e variância no espaço latente, um VAE força a média e o log da variância a ficarem próximos de 0.

Podemos fazer a primeira mudança substituindo o vetor latente por três camadas:

  1. Uma camada de média
  2. Uma camada de (log) variância
  3. Uma camada de amostragem

Cada conjunto de entradas mapeia para uma única média e o log da variância, suficientes para caracterizar uma normal. O modelo então amostra pontos dessa distribuição na camada de amostragem.

O bloco abaixo define uma função para criar a camada de amostragem. Ela recebe média e log da variância. Em seguida, usa tensorflow.random para gerar um tensor epsilon de uma normal padrão (média 0 e log variância 0). Depois, escalamos epsilon pelo desvio-padrão da distribuição alvo (obtido como exp(logVar/2)) e somamos a média.

# 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

Agora, modificamos o autoencoder para incluir as camadas mean, logVar e sampling. Note que a camada encoded agora recebe duas entradas e o encoder tem três saídas.

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

Assim como no autoencoder, nosso estado latente é a saída de encoded. Há, porém, uma diferença crucial: em vez de mapear cada entrada para um único estado latente, usamos a camada de sampling para amostrar valores de uma normal com parâmetros mean e logVar. Assim, a mesma entrada está associada a uma distribuição de estados latentes.

O passo final é ajustar a função de perda para disciplinar os valores de mean e logVar. Em particular, vamos calcular a divergência de Kullback-Leibler (KL) entre cada distribuição definida por mean e logVar e a normal padrão (média e log variância 0). Quanto mais longe de 0, maior o termo de perda KL.

Por que forçar cada distribuição a ficar próxima da normal padrão? Porque queremos amostrar estados latentes e usá-los para gerar música. Ao aplicar a penalização KL, podemos usar amostras independentes de uma normal padrão para obter estados latentes com boa chance de gerar músicas de qualidade.

No código abaixo, mostramos como modificar a perda para incorporar o termo KL. O primeiro componente é a perda de reconstrução (binary crossentropy) entre entradas e saídas do vae, como no autoencoder. O termo KL é a média da divergência KL entre as distribuições latentes e a normal padrão. Somamos ambos para obter a perda total, vaeLoss, adicionamos ao modelo, compilamos e treinamos.

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

Na seção anterior, vimos que o espaço latente do autoencoder era pouco estável. Um objetivo do VAE é corrigir isso em cenários generativos. A figura abaixo mostra o espaço latente do VAE: há boa dispersão nas duas dimensões, centradas em 0 e sem deriva ao longo das épocas — o que facilita amostrar estados latentes de qualidade.

VAE: evolução do espaço latente
This figure shows shifts in the latent space over time.

Por fim, geramos música. Amostramos um vetor latente da normal padrão, passamos ao decoder, remodelamos a saída e pegamos o argmax de cada coluna. Isso retorna 32 inteiros correspondentes a notas e acordes, identificados com intToChord. Então criamos um stream, adicionamos violão como instrumento, anexamos as notas/acordes e exportamos como 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')

As três músicas abaixo foram geradas pelo modelo. As duas primeiras usam estados latentes associados a sequências de acordes do treino. A terceira é uma combinação linear dos estados latentes das músicas #1 e #2. Como o VAE torna o espaço latente bem comportado, é razoável esperar que combinações lineares também resultem em bons estados.

Exemplo: música gerada #1
Exemplo: música gerada #2
Exemplo: combinação linear das músicas #1 e #2

Uma coisa que podemos notar nas músicas geradas por autoencoder e VAE é a falta de uma melodia forte. Podemos melhorar isso adicionando mais camadas ao modelo e aumentando o conjunto de treino. Alternativamente, podemos usar um modelo projetado para dados sequenciais, como veremos a seguir.

Long short-term memory (LSTM)

O último modelo é o LSTM, um tipo de rede neural recorrente (RNN) adaptada para evitar o problema do gradiente que se dissipa. Briot, Hadjerest e Pachet (2019) observam que modelos recorrentes são os mais comuns em geração musical, citando mais de 20 sistemas que usam RNN ou LSTM, incluindo BachBot, DeepBach, Performance-RNN e Hexahedria.

Usar LSTM traz dois benefícios em relação ao autoencoder:

  1. É projetado para processar dados sequenciais.
  2. Pode gerar sequências musicais de qualquer comprimento.

Ao contrário de autoencoders e VAEs, que usam entradas como alvos, modelos LSTM para geração aprendem a prever a próxima nota/acorde da sequência. Por isso, precisamos reconstruir os conjuntos de treino e alvo, como no código abaixo. Como LSTM reduz o número de parâmetros a treinar, também incluiremos as duraçõ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])

Note que trainChords e trainDurations são definidos como antes. Apenas adicionamos targetChords e targetDurations, que são o acorde e a duração seguintes às sequências de treino. Em seguida, definimos número de amostras, acordes e durações, além da dimensão de entrada. Usaremos camadas de embedding separadas para acordes e duraçõ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

O próximo passo é definir a arquitetura. Usaremos um modelo com duas entradas e duas saídas, para prever acordes e durações. Definimos as entradas chordInput e durationInput e passamos para camadas de embedding, chordEmbedding e durationEmbedding. Essas camadas são tabelas de consulta que associam um vetor de 64 elementos a cada acorde ou duração. Embeddings permitem substituir uma representação esparsa (one-hot) por uma densa, onde notas e durações relacionadas têm valores mais próximos.

As saídas dos embeddings são concatenadas em mergeLayer. Em seguida, passamos por duas camadas: uma LSTM com return_sequences=True para aproveitar estados intermediários (o que costuma melhorar desempenho, muitas vezes com atenção), e depois uma densa. Por fim, duas saídas: chordOutput e durationOutput. Definimos o modelo via API funcional do keras com as duas entradas e duas saídas.

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

Com o modelo definido, compilamos e iniciamos o treino. Mantivemos tudo o mais simples possível, mas em muitos casos faz sentido incluir regularização e camadas adicionais densas e LSTM. Note que o modelo tem duas entradas e duas saídas, como no diagrama abaixo. Você pode saber mais sobre modelos multi-input e multi-output no curso Introduction to TensorFlow in Python.

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

Concluído o treino, podemos gerar novas músicas com o LSTM. Alimentamos uma sequência inicial de 32 acordes e durações para fazer as primeiras previsões. Depois, anexamos essas previsões às séries e prevemos novamente com base nos últimos 32 elementos atualizados. Um benefício do LSTM é permitir iterar indefinidamente, gerando músicas tão longas quanto quisermos.

# 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

O passo final é exportar a música gerada para MIDI usando music21. O código é quase idêntico ao dos modelos baseados em autoencoder, com uma diferença importante: agora anexamos acordes e durações ao 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')

Com autoencoder e VAE, tivemos dificuldade em gerar melodias e ficamos presos a músicas de comprimento fixo. Com LSTM, podemos gerar sequências de qualquer tamanho iterando previsões, como no código acima. Também fica relativamente mais fácil criar melodias melhores. Veja isso nos exemplos abaixo, cada um iniciado com uma sequência de acordes e durações de uma amostra do nosso treino.

Exemplo: música gerada #1
Exemplo: música gerada #2
Exemplo: música gerada #3

   introduction to tensorflow in python course banner

Questões adicionais

Dados

Um dos grandes desafios para desenvolver modelos de machine learning para música é a disponibilidade de datasets, já que músicas geralmente não estão livres e legalmente disponíveis para download. Isso, inclusive, impediu a criação de um dataset padrão de referência, como o MNIST para classificação de imagens. Ainda assim, há alguns projetos que oferecem áudio bruto, notação simbólica ou features musicais derivadas para muitas músicas. Uma lista parcial:

  1. Mutopia Project. Um acervo de músicas em domínio público e creative commons nos formatos MIDI, PDF e LilyPond. Hoje tem mais de 2000 músicas e é organizado por instrumento, compositor, estilo e coleção.
  2. The Million Song Dataset. Contém features de áudio e metadados de mais de 300 GB de faixas populares, mas não os áudios.
  3. Free Music Archive. Contém áudio, rótulos de gênero e features para mais de 100.000 faixas livres e legais para download.

Amostragem vs. determinismo

Quando geramos música com LSTM, usamos uma abordagem determinística na seleção de acordes e durações. O modelo prevê probabilidades para cada um dos 267 acordes e 9 durações, e selecionamos os de maior probabilidade. De forma semelhante, nos modelos autoencoder e VAE, escolhemos os acordes com maiores valores de saída.

Uma alternativa é amostrar da distribuição de saídas, em vez de pegar os máximos. Isso torna o processo estocástico, não determinístico. Assim, modelos autoencoder ou VAE com o mesmo vetor latente podem gerar músicas diferentes a cada execução. O mesmo vale para uma mesma sequência de entrada em um LSTM.

O bloco abaixo mostra como tornar a geração estocástica modificando a função predictChords(). Substituímos o argmax() por uma amostragem que usa as probabilidades para sortear aleatoriamente acordes e durações.

# 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 uma discussão detalhada dos prós e contras de abordagens estocásticas e determinísticas, veja a seção 6.6 de Briot, Hadjerest e Pachet (2019).

Embeddings

No treino do LSTM, usamos uma camada de embeddings que mapeia cada acorde e duração para um vetor denso de 64 elementos. Usar embeddings evita entradas esparsas e de alta dimensão; em vez disso, temos uma representação compacta que captura relações entre acordes.

Em muitos casos, não faz sentido treinar uma camada de embedding do zero por falta de dados suficientes. Usar embeddings pré-treinados ou obtidos por aprendizado não supervisionado — como fazemos em tarefas de texto — pode viabilizar o treino quando seria inviável de outra forma.

Veja Chuan, Agres e Herremans (2018) para uma visão geral de como construir embeddings com aprendizado não supervisionado.

Generative adversarial networks

Falamos de autoencoders, VAEs e LSTM como opções para geração musical. Outra alternativa, bem-sucedida em outros domínios, é a generative adversarial network (GAN). Uma GAN combina uma rede discriminativa com uma generativa. A geradora cria amostras — como nossas sequências de acordes — que são misturadas com amostras reais no conjunto de treino. A discriminativa tenta classificar as amostras como reais ou geradas. A geradora tenta enganar a discriminativa para que classifique suas amostras como reais. O treino segue até um equilíbrio estável, em que nem discriminador nem gerador conseguem vantagem adicional.

Para quem quiser aplicar GANs à geração musical, vale conferir:

  1. MuseGAN: geração de música polifônica com GANs, com modelos pré-treinados e 174.154 músicas limpas e preparadas em formato pianoroll.

  2. Magenta: projeto open source focado em modelagem generativa para tarefas criativas, incluindo música. Explore as ferramentas via este notebook no Colab.

Recursos

Para saber mais sobre TensorFlow 2.0, veja o curso Introduction to TensorFlow in Python na DataCamp. Para uma revisão recente e completa de métodos de geração musical com deep learning, consulte Briot, Hadjerest e Pachet (2019). Para tutoriais adicionais sobre geração de música com deep learning, veja 1, 2, 3, 4 e 5, que oferece um tratamento completo sobre modelos generativos.

Apêndice

Representação contínua

Arquivos WAV são a forma contínua mais comum, pois contêm o sinal de áudio bruto e sem compressão. Diferem de mp3, por exemplo, que comprimem o arquivo descartando informações do sinal.

O código abaixo extrai a taxa de amostragem (rate) e o sinal bruto (signal) de um WAV com uma peça de Mozart. A taxa de amostragem é o número de vezes que o sinal é amostrado por segundo. No nosso 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))

O sinal bruto, signal, é um tensor S x C, onde S é o número de amostras e C o número de canais. Para simplificar, consideramos C = 1. O gráfico abaixo mostra um intervalo de três segundos de signal.

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

Em geral, usar sinais de áudio brutos para treinar modelos é bem mais desafiador do que usar representação discreta. Uma revisão recente de 32 métodos de geração musical com deep learning encontrou apenas três usando representações contínuas:

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

Transposição de tonalidade

Uma técnica útil tanto para redução de dimensionalidade quanto para aumento de dados é a transposição de tonalidade, que desloca a altura de cada nota por um intervalo fixo. Se uma música tem a sequência C3 seguida de D3 e aplicamos uma transposição de +2, então C3 vira D3 e D3 vira E3. Para modelos que usam transposição, veja Lim, Rhyu e Lee (2018) e Sturm et al. (2016).

Se usarmos transposição para reduzir dimensionalidade, a meta é deslocar notas e acordes de modo a minimizar o número total de elementos únicos no dataset. Como músicas tendem a ser escritas em torno de grupos de alturas chamados tonalidades que soam bem juntas, podemos transpor todas as músicas para a mesma tonalidade.

Desta vez, vamos importar três submódulos do music21:

  1. converter, para carregar o MIDI e converter em stream.
  2. pitch, para criar a altura alvo.
  3. interval, para calcular a distância entre a tonalidade original e a desejada.
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

Aplicamos .analyze('key') em score para identificar a tonalidade (C major). Depois, calculamos o intervalo entre C major e a tonalidade alvo, F major. Acessamos a tônica com .tonic e criamos a altura alvo com pitch.Pitch('F'). Atribuímos o resultado a keyInterval, que indica a distância entre as tonalidades.

Por fim, transponhamos a peça com .transpose() passando keyInterval. Imprimir a nova tonalidade confirma F major.

Transposição de oitava

Podemos ir além e transpor também a oitava, para cima ou para baixo. Em vez de transpor C major para F major, por exemplo, podemos transpor C major para C major em outra oitava. Se padronizarmos as oitavas alvo, forçamos notas e acordes a se agruparem, reduzindo a dimensionalidade e facilitando o treino. No código acima, bastaria especificar a oitava da altura: por exemplo, pitch.Pitch('F3') em vez de pitch.Pitch('F').

Invariância a tonalidade e oitava

Uma alternativa à redução de dimensionalidade é o aumento de dados. Em vez de reduzir o conjunto de possíveis entradas e saídas, expandimos o dataset a partir de alterações nos dados existentes. Também podemos fazer isso com transposição de tonalidade e oitava. Porém, em vez de transpor tudo para uma tonalidade comum e oitavas adjacentes, transporíamos todas as músicas para todas as tonalidades e oitavas. Assim, alcançaríamos invariância a tonalidade e oitava no modelo. Veja Briot, Hadjerest e Pachet (2019) para discussão.

Coleta de dados

Vamos coletar os arquivos MIDI para treinar nosso modelo no Project Mutopia. Antes de fazer scraping, verifique o arquivo robots, em https://www.mutopiaproject.org/robots.txt, para respeitar as diretrizes do site. Na época deste tutorial, o arquivo continha o texto abaixo. User-agent: * indica que a regra se aplica a todos os usuários; Disallow: vazio significa que não há restrições.

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

Vamos importar urlopen e urlretrieve de urllib.request para fazer requisições a cada página da seção de violão e baixar os arquivos MIDI vinculados. Importaremos BeautifulSoup para analisar o HTML retornado por urlopen e identificar os links dos MIDIs. Por fim, usaremos time para pausar 10 segundos entre downloads e não sobrecarregar o site.

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

Em cada iteração do loop principal, verificamos se linkCount — o número de links na página — é maior que 0. Se for, construímos a URL da próxima página, url, usando as strings url0, url1 e o número da música, songNumber. Abrimos a URL, analisamos o HTML, buscamos todos os links, zeramos linkCount e percorremos os links. Se um link contém a substring .mid, ele aponta para um arquivo MIDI e é baixado com urlretrieve() para o diretório save_dir. Por fim, incrementamos songNumber em 10, já que cada página contém 10 músicas. Repetimos até encontrar uma página com menos de 10 links.

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

Para saber mais sobre scraping, veja o curso Web Scraping with Python e o tutorial Web Scraping using Python da DataCamp.

Tópicos
Python
Aprendizado de máquina

Aprenda mais sobre Python e machine learning

Curso

Introdução ao TensorFlow em Python

4 h
56.5K
Aprenda os fundamentos das redes neurais e como criar modelos de aprendizado profundo usando TensorFlow.
Ver detalhesRight Arrow
Iniciar Curso
Ver maisRight Arrow
Relacionado

blog

O que é um modelo generativo?

Os modelos generativos usam o aprendizado de máquina para descobrir padrões nos dados e gerar novos dados. Saiba mais sobre sua importância e aplicações em IA.
Abid Ali Awan's photo

Abid Ali Awan

11 min

Tutorial

Tutorial de manipulação de dados categóricos de aprendizado de máquina com Python

Aprenda os truques comuns para lidar com dados categóricos e pré-processá-los para criar modelos de aprendizado de máquina!
Moez Ali's photo

Moez Ali

14 min

Tutorial

Introdução ao Q-learning: um tutorial para iniciantes

Aprenda o algoritmo de aprendizado por reforço sem modelo mais popular com um tutorial em Python.
Abid Ali Awan's photo

Abid Ali Awan

11 min

Tutorial

Tutorial de lambda em Python

Aprenda uma maneira mais rápida de escrever funções em tempo real com as funções lambda.
DataCamp Team's photo

DataCamp Team

3 min

Tutorial

Tutorial do modelo de transformador no PyTorch: Da teoria ao código

Saiba como criar um modelo Transformer usando o PyTorch, uma ferramenta poderosa do machine learning moderno.
Arjun Sarkar's photo

Arjun Sarkar

15 min

Tutorial

Funções em Python: como chamar e escrever funções

Descubra como escrever funções em Python reutilizáveis e eficientes. Domine parâmetros, instruções de retorno e temas avançados como funções lambda. Organize melhor seu código com main() e outras boas práticas.
Karlijn Willems's photo

Karlijn Willems

14 min

Ver MaisVer Mais