Cách tạo nhạc bằng Machine Learning

Dec 10 2022
Bạn đã bao giờ muốn tạo nhạc bằng Python và Machine Learning chưa? Hãy xem làm thế nào chúng ta có thể làm điều đó! Là một người đam mê âm nhạc và là một nhà khoa học dữ liệu, tôi luôn tự hỏi liệu có cách nào để kết hợp âm nhạc với học máy và tạo ra Âm nhạc do AI tạo ra hay không. Vâng, có! Có một số cách để tiếp cận chủ đề này, một cách là sử dụng mô hình chuỗi (như GRU hoặc LSTM) và tạo chuỗi nốt và/hoặc hợp âm dựa trên n chuỗi trước đó.

Bạn đã bao giờ muốn tạo nhạc bằng Python và Machine Learning chưa? Hãy xem làm thế nào chúng ta có thể làm điều đó!

Ảnh của Namroud Gorguis trên Bapt

Là một người đam mê âm nhạc và là một nhà khoa học dữ liệu, tôi luôn tự hỏi liệu có cách nào để kết hợp âm nhạc với học máy và tạo ra Âm nhạc do AI tạo ra hay không . Vâng, có! Có một số cách để tiếp cận chủ đề này, một cách là sử dụng mô hình chuỗi (như GRU hoặc LSTM) và tạo chuỗi nốt và/hoặc hợp âm dựa trên n chuỗi trước đó. Một cách khác là xử lý âm thanh thô thành VAE (Bộ mã hóa tự động biến đổi) có thể đào tạo và để nó phát ra các âm thanh khác nhau.

Chúng tôi sẽ sử dụng cái trước cho bài viết này và thử cái sau vào lúc khác.

Đối với điều này, chúng tôi sẽ cần một bộ dữ liệu âm nhạc khổng lồ (tốt nhất là tất cả thuộc về một thể loại âm nhạc cụ thể hoặc tương tự) để đưa vào mô hình trình tự của chúng tôi để hy vọng chúng tôi có thể cố gắng tạo lại một số bài hát hoặc tạo bài hát của riêng mình.

Đối với dự án này, chúng tôi sẽ làm việc với thể loại LoFi. Tôi đã tìm thấy một bộ dữ liệu tuyệt vời chứa đầy các phân đoạn LoFi sẽ giúp chúng tôi có được âm thanh LoFi mà chúng tôi đang hướng tới. Bộ dữ liệu này đến từ Kaggle và một số nguồn khác.

Bây giờ chúng tôi đã có được tập dữ liệu đầy MIDI, làm cách nào để biến nó thành dữ liệu có thể đọc được cho máy của chúng tôi? Chúng tôi sẽ sử dụng music21 để chuyển đổi tập dữ liệu chứa đầy MIDI thành một danh sách các chuỗi nốt và hợp âm.

Trước tiên, chúng ta cần lấy thư mục lưu trữ 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)

DataFrame cho network_input

Nếu tập dữ liệu không cân bằng, điều đó không sao cả, không phải mọi nốt/hợp âm đều xuất hiện thường xuyên như nhau, nhưng chúng ta có thể vấp phải trường hợp một nốt xuất hiện hơn 4000 lần và một nốt khác chỉ xảy ra một lần. Chúng tôi có thể thử lấy mẫu quá mức tập dữ liệu, nhưng điều này không phải lúc nào cũng mang lại kết quả tốt nhất, nhưng có thể đáng để thử. Đối với trường hợp của chúng tôi, chúng tôi sẽ không lấy mẫu quá mức khi bộ dữ liệu của chúng tôi được cân bằng.

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)

  • Đã thu thập các tệp MIDI của chúng tôi
  • Đã tải các tệp MIDI vào bộ nhớ
  • Đã chuyển đổi các tệp MIDI thành danh sách các nốt/hợp âm được sắp xếp theo trình tự
  • Đã chuyển đổi danh sách thành ma trận (n, m, 1) và (n, 1) vector (n = 99968, m = 32)

LSTM là một loại mạng thần kinh tái phát, nhưng khác với các mạng khác. Các mạng khác lặp lại mô-đun mỗi khi mục nhận được thông tin mới. Tuy nhiên, LSTM sẽ nhớ bài toán lâu hơn và có cấu trúc dạng chuỗi để lặp lại mô-đun.

LSTM về cơ bản là các đơn vị như được mô tả:

Hình ảnh lấy từ https://en.wikipedia.org/wiki/Long_short-term_memory

Một đơn vị LSTM bao gồm một ô, một cổng đầu vào, một cổng đầu ra và một cổng quên. Chúng ta hãy xem điều này có nghĩa là gì và tại sao LSTM lại tốt cho dữ liệu tuần tự.

Công việc của cổng quên là quyết định giữ hay quên thông tin. Chỉ thông tin đến từ các lớp ẩn trước đó và đầu vào hiện tại được lưu giữ với chức năng sigmoid. Bất kỳ giá trị nào gần với một sẽ vẫn còn và bất kỳ giá trị nào gần với 0 sẽ biến mất.

Cổng vào giúp cập nhật trạng thái của các ô. Thông tin đầu vào hiện tại và trạng thái trước đó được truyền qua hàm sigmoid , hàm này sẽ cập nhật giá trị bằng cách nhân nó với 0 và 1. Tương tự, để điều chỉnh mạng, dữ liệu cũng đi qua hàm tanh . Bây giờ, đầu ra của sigmoid được nhân với đầu ra của tanh . Đầu ra của sigmoid sẽ xác định thông tin có giá trị để tránh đầu ra của tanh .

Cổng đầu ra xác định giá trị của trạng thái ẩn tiếp theo. Để tìm thông tin trạng thái ẩn, chúng ta cần nhân đầu ra sigmoid với đầu ra tanh . Bây giờ trạng thái ẩn mới và trạng thái ô mới sẽ chuyển sang bước tiếp theo.

Khi đào tạo mạng LSTM, yêu cầu sử dụng GPU. Trong trường hợp của tôi, tôi đã sử dụng Google Colab Pro khi huấn luyện mạng thần kinh. Google Colab có một giới hạn nhất định về đơn vị tính toán mà chúng tôi có thể sử dụng khi đào tạo bằng GPU. Bạn có thể sử dụng GPU miễn phí trong vài chục kỷ nguyên.

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)

      
                

Các biểu đồ sau đây cho thấy kết quả đào tạo mạng lưới thần kinh.

Biểu đồ bên trái cho thấy độ chính xác liên quan đến các kỷ nguyên. Biểu đồ bên phải cho thấy sự mất mát liên quan đến các kỷ nguyên.

Chúng ta sẽ làm gì khi đào tạo xong mạng lưới của mình? Chúng tôi chọn một số ngẫu nhiên từ 0 đến độ dài của đầu vào mạng, đây sẽ là chỉ mục của hàng trong ma trận đào tạo mà chúng tôi sẽ sử dụng để đưa ra dự đoán của mình. Chúng tôi lấy chuỗi 32 nốt/hợp âm này làm điểm bắt đầu để đưa ra dự đoán về 1 nốt. Sau đó, chúng tôi làm điều này (n - 1) lần nữa (n là 500 trong trường hợp này). Trong mọi dự đoán, chúng tôi di chuyển một cửa sổ gồm 32 nốt/hợp âm sang bên phải một phần tử. Nói cách khác, trong dự đoán thứ hai, khi chúng tôi đã dự đoán một nốt/hợp âm, chúng tôi loại bỏ nốt đầu tiên và dự đoán đầu tiên của chúng tôi trở thành nốt/hợp âm cuối cùng trong chuỗi độ dài 32. Các hình ảnh sau đây hiển thị mã được giải thích trước đó

Trình tự minh họa của dự đoán đầu tiên
Trình tự minh họa của dự đoán thứ hai

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

TÙY CHỌN: Khi đào tạo mạng nơ-ron, mỗi epoch sẽ mang lại một tập trọng số khác nhau, trong quá trình tạo nhạc, mỗi tập hợp trọng số sẽ mang lại một kết quả khác nhau (chuỗi nốt/hợp âm khác nhau), vì vậy tốt nhất bạn nên theo dõi từng trọng số.

Bây giờ, chúng ta có thể tiến thêm một bước và kiểm tra các dự đoán từ tất cả các trọng số đã lưu trước đó. Trước tiên, hãy lấy vị trí mà chúng tôi đã lưu trữ các trọng số và lặp qua thư mục đó:

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

Tất cả mã được hiển thị ở đây đều nằm trong GitHub của tôi nếu bạn muốn sao chép những gì bạn thấy ở đây. Với tất cả thông tin này, bạn sẽ có thể tạo các bài hát của riêng mình. Nếu bạn làm như vậy, xin vui lòng tải chúng lên một nơi nào đó và liên kết chúng với tôi để tôi có thể kiểm tra chúng!

Ngoài ra, xin chân thành cảm ơn Zachary vì đã viết bài báo này , đó là nguồn cảm hứng cho tôi.

Như mọi khi, cảm ơn vì đã dành thời gian để đọc điều này. Tôi hy vọng bạn đã học được một cái gì đó ngày hôm nay!