Como gerar música usando Machine Learning

Dec 10 2022
Sempre quis gerar música usando Python e Machine Learning? Vamos dar uma olhada em como podemos fazer isso! Como entusiasta da música e cientista de dados, sempre me perguntei se havia uma maneira de misturar música e aprendizado de máquina e criar música gerada por IA. Bem, existe! Existem várias maneiras de abordar este tópico, uma delas é usar um modelo de sequência (como um GRU ou um LSTM) e criar uma sequência de notas e/ou acordes com base em n sequências anteriores.

Sempre quis gerar música usando Python e Machine Learning? Vamos dar uma olhada em como podemos fazer isso!

Foto de Namroud Gorguis no Unsplash

Como entusiasta da música e cientista de dados, sempre me perguntei se havia uma maneira de misturar música e aprendizado de máquina e criar música gerada por IA . Bem, existe! Existem várias maneiras de abordar este tópico, uma delas é usar um modelo de sequência (como um GRU ou um LSTM) e criar uma sequência de notas e/ou acordes com base em n sequências anteriores. Outra maneira é processar o áudio bruto em um VAE (Variational Autoencoder) treinável e fazer com que ele emita sons diferentes.

Usaremos o primeiro neste artigo e tentaremos o segundo em outro momento.

Para isso, precisaremos de um vasto conjunto de dados de música (de preferência todos pertencentes a um gênero musical específico ou similar) para alimentar nosso modelo de sequência, para que possamos tentar recriar algumas das músicas ou criar nossas próprias.

Para este projeto, trabalharemos com o gênero LoFi. Encontrei um ótimo conjunto de dados preenchido com segmentos LoFi que nos ajudarão a obter o som LoFi que buscamos. Este conjunto de dados vem do Kaggle e de várias outras fontes.

Agora que adquirimos nosso conjunto de dados cheio de MIDIs, como podemos transformá-lo em dados legíveis para nossa máquina? Estaremos usando o music21 para converter nosso conjunto de dados cheio de MIDIs em uma lista de sequências de notas e acordes.

Primeiro precisamos obter o diretório onde temos nossos MIDIs armazenados

from pathlib import Path

songs = []
folder = Path('insert directory here')
for file in folder.rglob('*.mid'):
  songs.append(file)

import random
# Get a subset of 1000 songs
result =  random.sample([x for x in songs], 1000)

from music21 import converter, instrument, note, chord
notes = []
for i,file in enumerate(result):
    print(f'{i+1}: {file}')
    try:
      midi = converter.parse(file)
      notes_to_parse = None
      parts = instrument.partitionByInstrument(midi)
      if parts: # file has instrument parts
          notes_to_parse = parts.parts[0].recurse()
      else: # file has notes in a flat structure
          notes_to_parse = midi.flat.notes
      for element in notes_to_parse:
          if isinstance(element, note.Note):
              notes.append(str(element.pitch))
          elif isinstance(element, chord.Chord):
              notes.append('.'.join(str(n) for n in element.normalOrder))
    except:
      print(f'FAILED: {i+1}: {file}')

import pickle
with open('notes', 'wb') as filepath:
  pickle.dump(notes, filepath)

>>> ['C2', 'A4', 'F1', 'F1', ..., '0.6', '0.4.7']

def prepare_sequences(notes, n_vocab):
    """ Prepare the sequences used by the Neural Network """
    sequence_length = 32

    # Get all unique pitchnames
    pitchnames = sorted(set(item for item in notes))
    numPitches = len(pitchnames)

     # Create a dictionary to map pitches to integers
    note_to_int = dict((note, number) for number, note in enumerate(pitchnames))

    network_input = []
    network_output = []

    # create input sequences and the corresponding outputs
    for i in range(0, len(notes) - sequence_length, 1):
        # sequence_in is a sequence_length list containing sequence_length notes
        sequence_in = notes[i:i + sequence_length]
        # sequence_out is the sequence_length + 1 note that comes after all the notes in
        # sequence_in. This is so the model can read sequence_length notes before predicting
        # the next one.
        sequence_out = notes[i + sequence_length]
        # network_input is the same as sequence_in but it containes the indexes from the notes
        # because the model is only fed the indexes.
        network_input.append([note_to_int[char] for char in sequence_in])
        # network_output containes the index of the sequence_out
        network_output.append(note_to_int[sequence_out])

    # n_patters is the length of the times it was iterated 
    # for example if i = 3, then n_patterns = 3
    # because network_input is a list of lists
    n_patterns = len(network_input)

    # reshape the input into a format compatible with LSTM layers
    # Reshapes it into a n_patterns by sequence_length matrix
    print(len(network_input))
    
    network_input = numpy.reshape(network_input, (n_patterns, sequence_length, 1))
    # normalize input
    network_input = network_input / float(n_vocab)

    # OneHot encodes the network_output
    network_output = np_utils.to_categorical(network_output)

    return (network_input, network_output)


n_vocab = len(set(notes))
network_input, network_output = prepare_sequences(notes,n_vocab)
n_patterns = len(network_input)
pitchnames = sorted(set(item for item in notes))
numPitches = len(pitchnames)

DataFrame para network_input

Se o conjunto de dados estiver desequilibrado, tudo bem, nem todas as notas/acordes são igualmente frequentes, mas podemos nos deparar com casos em que uma nota ocorre mais de 4.000 vezes e outra ocorre apenas uma vez. Podemos tentar sobreamostrar o conjunto de dados, mas isso nem sempre produz os melhores resultados, mas pode valer a pena tentar. Para o nosso caso, não faremos oversampling, visto que nosso conjunto de dados está balanceado.

def oversample(network_input,network_output,sequence_length=15):

  n_patterns = len(network_input)
  # Create a DataFrame from the two matrices
  new_df = pd.concat([pd.DataFrame(network_input),pd.DataFrame(network_output)],axis=1)

  # Rename the columns to numbers and Notes
  new_df.columns = [x for x in range(sequence_length+1)]
  new_df = new_df.rename(columns={sequence_length:'Notes'})

  print(new_df.tail(20))
  print('###################################################')
  print(f'Distribution of notes in the preoversampled DataFrame: {new_df["Notes"].value_counts()}')
  # Oversampling
  oversampled_df = new_df.copy()
  #max_class_size = np.max(oversampled_df['Notes'].value_counts())
  max_class_size = 700
  print('Size of biggest class: ', max_class_size)

  class_subsets = [oversampled_df.query('Notes == ' + str(i)) for i in range(len(new_df["Notes"].unique()))] # range(2) because it is a binary class

  for i in range(len(new_df['Notes'].unique())):
    try:
      class_subsets[i] = class_subsets[i].sample(max_class_size,random_state=42,replace=True)
    except:
      print(i)

  oversampled_df = pd.concat(class_subsets,axis=0).sample(frac=1.0,random_state=42).reset_index(drop=True)

  print('###################################################')
  print(f'Distribution of notes in the oversampled DataFrame: {oversampled_df["Notes"].value_counts()}')

  # Get a sample from the oversampled DataFrame (because it may be too big, and we also have to convert it into a 3D array for the LSTM)
  sampled_df = oversampled_df.sample(n_patterns,replace=True) # 99968*32 has to be equals to (99968,32,1)

  print('###################################################')
  print(f'Distribution of notes in the oversampled post-sampled DataFrame: {sampled_df["Notes"].value_counts()}')

  # Convert the training columns back to a 3D array
  network_in = sampled_df[[x for x in range(sequence_length)]]
  network_in = np.array(network_in)
  network_in = np.reshape(networkInput, (n_patterns, sequence_length, 1))
  network_in = network_in / numPitches
  print(network_in.shape)
  print(sampled_df['Notes'].shape)
  # Converts the target column into a OneHot encoded matrix
  network_out = pd.get_dummies(sampled_df['Notes'])
  print(network_out.shape)

  return network_in,network_out

networkInputShaped,networkOutputShaped = oversample(networkInput,networkOutput,sequence_length=seqLength)
networkOutputShaped = np_utils.to_categorical(networkOutput)

  • Coletamos nossos arquivos MIDI
  • Carregou os arquivos MIDI na memória
  • Transformou os arquivos MIDI em uma lista de notas/acordes sequenciados
  • Transformou a lista em uma matriz (n, m, 1) e (n, 1) vetor (n = 99968, m = 32)

LSTMs são um tipo de rede neural recorrente, mas são diferentes de outras redes. Outras redes repetem o módulo cada vez que a entrada recebe novas informações. No entanto, o LSTM se lembrará do problema por mais tempo e terá uma estrutura semelhante a string para repetir o módulo.

LSTM são basicamente unidades conforme representado:

Imagem retirada de https://en.wikipedia.org/wiki/Long_short-term_memory

Uma unidade LSTM é composta por uma célula, uma porta de entrada, uma porta de saída e uma porta de esquecimento. Vamos dar uma olhada no que isso significa e por que os LSTMs são bons para dados sequenciais.

O trabalho do portão de esquecimento é decidir se deve manter ou esquecer a informação. Apenas as informações que vêm de camadas ocultas anteriormente e a entrada atual são mantidas com a função sigmoide. Qualquer valor mais próximo de um permanecerá e qualquer valor mais próximo de zero desaparecerá.

O portão de entrada ajuda a atualizar o status das células. A entrada atual e as informações do estado anterior são passadas pela função sigmoide , que atualizará o valor multiplicando-o por 0 e 1. Da mesma forma, para regular a rede, os dados também passam pela função tanh . Agora, a saída do sigmóide é multiplicada pela saída de tanh . A saída do sigmóide identificará informações valiosas para evitar a saída de tanh .

A porta de saída determina o valor do próximo estado oculto. Para encontrar as informações do estado oculto, precisamos multiplicar a saída sigmoide pela saída tanh . Agora, o novo estado oculto e o novo estado da célula irão para a próxima etapa.

Ao treinar uma rede LSTM, é necessário usar uma GPU. No meu caso, usei o Google Colab Pro ao treinar a rede neural. O Google Colab tem um limite definido de unidades de computação que podemos usar ao treinar com GPUs. Você pode usar a GPU gratuita por algumas dezenas de épocas.

model = Sequential()
model.add(Dropout(0.2))
model.add(LSTM(
    512,
    input_shape=(networkInputShaped.shape[1], networkInputShaped.shape[2]),
    return_sequences=True
))
model.add(Dense(256))
model.add(Dense(256))
model.add(LSTM(512, return_sequences=True))
model.add(Dense(256))
model.add(LSTM(512))
#model.add(Dense(numPitches))
model.add(Dense(numPitches))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['accuracy'])

num_epochs = 100

filepath = "weights-improvement-{epoch:02d}-{loss:.4f}-bigger_1.hdf5"
checkpoint = ModelCheckpoint(
    filepath, monitor='loss', 
    verbose=1,        
    save_best_only=True,        
    mode='min'
)    
callbacks_list = [checkpoint]

history = model.fit(networkInputShaped, networkOutputShaped, epochs=num_epochs, batch_size=64, callbacks=callbacks_list)

      
                

Os gráficos a seguir mostram o resultado do treinamento da rede neural.

O gráfico à esquerda mostra a precisão em relação às épocas. O gráfico à direita mostra a perda em relação às épocas.

O que fazemos quando terminamos de treinar nossa rede? Escolhemos um número aleatório de 0 ao comprimento da entrada da rede, este será o índice da linha na matriz de treinamento que usaremos para fazer nossas previsões. Tomamos esta sequência de 32 notas/acordes como ponto de partida para fazer uma previsão de 1 nota. Depois disso, fazemos isso (n — 1) mais vezes (n sendo 500 neste caso). Em cada previsão movemos uma janela de 32 notas/acordes um elemento para a direita. Em outras palavras, na segunda predição, uma vez predita uma nota/acorde, eliminamos a primeira nota, e nossa primeira predição passa a ser a última nota/acorde na sequência de comprimento 32. As imagens a seguir mostram o código explicado anteriormente

Sequência de ilustração da primeira previsão
Sequência de ilustração da segunda previsão

def generate_notes(model, network_input, pitchnames, n_vocab):
    """ Generate notes from the neural network based on a sequence of notes """
    # pick a random sequence from the input as a starting point for the prediction
    # Selects a random row from the network_input
    start = numpy.random.randint(0, len(network_input)-1)
    print(f'start: {start}')
    int_to_note = dict((number, note) for number, note in enumerate(pitchnames))

    # Random row from network_input
    pattern = network_input[start]
    prediction_output = []

    # generate 500 notes
    for note_index in range(500):
        # Reshapes pattern into a vector
        prediction_input = numpy.reshape(pattern, (1, len(pattern), 1))
        # Standarizes pattern
        prediction_input = prediction_input / float(n_vocab)

        # Predicts the next note
        prediction = model.predict(prediction_input, verbose=0)

        # Outputs a OneHot encoded vector, so this picks the columns
        # with the highest probability
        index = numpy.argmax(prediction)
        # Maps the note to its respective index
        result = int_to_note[index]
        # Appends the note to the prediction_output
        prediction_output.append(result)

        # Adds the predicted note to the pattern
        pattern = numpy.append(pattern,index)
        # Slices the array so that it contains the predicted note
        # eliminating the first from the array, so the model can
        # have a sequence
        pattern = pattern[1:len(pattern)]

    return prediction_output

n_vocab = len(set(allNotes))
pitchnames = sorted(set(item for item in allNotes))
prediction_output = generate_notes(model, networkInputShaped, pitchnames, n_vocab)

>>> ['B2', 'B2', 2.7, ..., 5.10]

def create_midi(prediction_output):
    offset = 0
    output_notes = []

    # create note and chord objects based on the values generated by the model
    for pattern in prediction_output:
        # pattern is a chord
        if ('.' in pattern) or pattern.isdigit():
            notes_in_chord = pattern.split('.')
            notes = []
            for current_note in notes_in_chord:
                new_note = note.Note(int(current_note))
                new_note.storedInstrument = instrument.Piano()
                notes.append(new_note)
            new_chord = chord.Chord(notes)
            new_chord.offset = offset
            output_notes.append(new_chord)
        # pattern is a note
        else:
            new_note = note.Note(pattern)
            new_note.offset = offset
            new_note.storedInstrument = instrument.Piano()
            output_notes.append(new_note)

        # increase offset each iteration so that notes do not stack
        offset += 0.5
    midi_stream = stream.Stream(output_notes)
    midi_stream.write('midi', fp='output.mid')

OPCIONAL: Ao treinar uma rede neural, cada época produzirá um conjunto diferente de pesos, na geração de música, cada conjunto de pesos produzirá um resultado diferente (sequência diferente de notas/acordes), por isso é melhor acompanhar cada peso.

Agora, podemos dar um passo adiante e verificar as previsões de todos os pesos salvos anteriormente. Primeiro, vamos obter o local onde armazenamos os pesos e iterar nessa pasta:

songs = []
folder = Path('Training Weights LoFi')
for file in folder.rglob('*.hdf5'):
  songs.append(file)

songsList = []
weightsList = []
for i in range(len(songs)):
  try:
    model.load_weights(songs[i])
    prediction_output = generate_notes(model, networkInputShaped, pitchnames, n_vocab)
    songsList.append(prediction_output)
    weightsList.append(str(songs[i]))
  except:
    pass

songs_df = pd.DataFrame({'Weights':weightsList,
                         'Notes':songsList})

Todo o código mostrado aqui está no meu GitHub caso você queira replicar o que viu aqui. Com todas essas informações, você poderá criar suas próprias músicas. Se você fizer isso, por favor, carregue-os em algum lugar e vincule-os para mim para que eu possa verificá-los!

Além disso, um grande obrigado a Zachary por escrever este artigo , de onde tirei a inspiração.

Como sempre, obrigado por reservar um tempo para ler isso. Espero que você tenha aprendido alguma coisa hoje!