Cómo generar música usando Machine Learning

Dec 10 2022
¿Alguna vez quisiste generar música usando Python y Machine Learning? ¡Echemos un vistazo a cómo podemos hacerlo! Como entusiasta de la música y científico de datos, siempre me he preguntado si había una manera de mezclar música y aprendizaje automático y crear música generada por IA. Bueno, ¡hay! Hay varias formas de abordar este tema, una forma es usar un modelo de secuencia (como un GRU o un LSTM) y crear una secuencia de notas y/o acordes basados ​​en secuencias n-previas.

¿Alguna vez quisiste generar música usando Python y Machine Learning? ¡Echemos un vistazo a cómo podemos hacerlo!

Foto de Namroud Gorguis en Unsplash

Como entusiasta de la música y científico de datos, siempre me he preguntado si había una manera de mezclar música y aprendizaje automático y crear música generada por IA . Bueno, ¡hay! Hay varias formas de abordar este tema, una forma es usar un modelo de secuencia (como un GRU o un LSTM) y crear una secuencia de notas y/o acordes basados ​​en secuencias n-previas. Otra forma es procesar el audio sin procesar en un VAE (autocodificador variacional) entrenable y hacer que emita diferentes sonidos.

Usaremos el primero para este artículo y probaremos el segundo en otro momento.

Para esto, necesitaremos un vasto conjunto de datos de música (preferiblemente todos pertenecientes a un género musical específico o similar) para alimentar nuestro modelo de secuencia para que podamos intentar recrear algunas de las canciones, o crear las nuestras.

Para este proyecto, trabajaremos con el género LoFi. Encontré un gran conjunto de datos lleno de segmentos LoFi que nos ayudarán a obtener el sonido LoFi que buscamos. Este conjunto de datos proviene de Kaggle y varias otras fuentes.

Ahora que hemos adquirido nuestro conjunto de datos lleno de MIDI, ¿cómo lo convertimos en datos legibles para nuestra máquina? Usaremos music21 para convertir nuestro conjunto de datos lleno de MIDI en una lista de secuencias de notas y acordes.

Primero necesitamos obtener el directorio donde tenemos almacenados nuestros MIDI

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)

Marco de datos para network_input

Si el conjunto de datos está desequilibrado, está bien, no todas las notas/acordes son igualmente frecuentes, pero podemos tropezar con casos en los que tenemos una nota que aparece más de 4000 veces y otra que solo ocurre una vez. Podemos intentar sobremuestrear el conjunto de datos, pero esto no siempre produce los mejores resultados, pero podría valer la pena intentarlo. Para nuestro caso, no estaremos sobremuestreando ya que nuestro conjunto de datos 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)

  • Recopilamos nuestros archivos MIDI
  • Cargó los archivos MIDI en la memoria
  • Transformó los archivos MIDI en una lista de notas/acordes secuenciados
  • Transformó la lista en una matriz (n, m, 1) y un vector (n, 1) (n = 99968, m = 32)

Los LSTM son un tipo de red neuronal recurrente, pero son diferentes de otras redes. Otras redes repiten el módulo cada vez que la entrada recibe nueva información. Sin embargo, el LSTM recordará el problema por más tiempo y tiene una estructura similar a una cadena para repetir el módulo.

LSTM son básicamente unidades como se muestra:

Imagen tomada de https://en.wikipedia.org/wiki/Long_short-term_memory

Una unidad LSTM se compone de una celda, una puerta de entrada, una puerta de salida y una puerta de olvido. Echemos un vistazo a lo que esto significa y por qué los LSTM son buenos para datos secuenciales.

El trabajo de la puerta de olvido es decidir si conservar u olvidar la información. Solo la información que proviene de capas previamente ocultas y la entrada actual se mantiene con la función sigmoide. Cualquier valor cercano a uno permanecerá y cualquier valor cercano a cero desaparecerá.

La puerta de entrada ayuda a actualizar el estado de las celdas. La entrada actual y la información del estado anterior se pasan a través de la función sigmoid , que actualizará el valor multiplicándolo por 0 y 1. De manera similar, para regular la red, los datos también pasan por la función tanh . Ahora, la salida del sigmoide se multiplica por la salida de tanh . La salida del sigmoide identificará información valiosa para evitar la salida de tanh .

La puerta de salida determina el valor del siguiente estado oculto. Para encontrar la información de estado oculta, necesitamos multiplicar la salida sigmoidea por la salida de tanh . Ahora el nuevo estado oculto y el nuevo estado de celda viajarán al siguiente paso.

Al entrenar una red LSTM se requiere usar una GPU. En mi caso, utilicé Google Colab Pro al entrenar la red neuronal. Google Colab tiene un límite establecido de unidades de cómputo que podemos usar cuando entrenamos con GPU. Puede usar la GPU gratuita durante un par de docenas 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)

      
                

Los siguientes gráficos muestran el resultado del entrenamiento de la red neuronal.

El gráfico de la izquierda muestra la precisión en relación con las épocas. El gráfico de la derecha muestra la pérdida en relación con las épocas.

¿Qué hacemos cuando terminamos de entrenar nuestra red? Elegimos un número aleatorio de 0 a la longitud de la entrada de la red, este será el índice de la fila en la matriz de entrenamiento que usaremos para hacer nuestras predicciones. Tomamos esta secuencia de 32 notas/acordes como punto de partida para hacer una predicción de 1 nota. Después de esto, hacemos esto (n — 1) veces más (siendo n 500 en este caso). En cada predicción movemos una ventana de 32 notas/acordes un elemento a la derecha. Es decir, en la segunda predicción, una vez que hemos predicho una nota/acorde, eliminamos la primera nota, y nuestra primera predicción se convierte en la última nota/acorde en la secuencia de longitud 32. Las siguientes imágenes muestran el código explicado anteriormente

Secuencia de ilustraciones de la primera predicción
Secuencia de ilustración de la segunda predicción

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: Al entrenar una red neuronal, cada época generará un conjunto diferente de pesos, en la generación de música, cada conjunto de pesos generará un resultado diferente (diferente secuencia de notas/acordes), por lo que es mejor realizar un seguimiento de cada peso.

Ahora, podemos ir un paso más allá y comprobar las predicciones de todos los pesos guardados previamente. Primero, obtengamos la ubicación donde almacenamos los pesos e iteremos a través de esa carpeta:

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 el código que se muestra aquí está en mi GitHub si desea replicar lo que vio aquí. Con toda esta información, deberías poder crear tus propias canciones. Si es así, cárguelos en algún lugar y vincúlelos para que pueda verlos.

Además, muchas gracias a Zachary por escribir este artículo , que es de donde obtuve la inspiración.

Como siempre, gracias por tomarse el tiempo de leer esto. ¡Espero que hayas aprendido algo hoy!