使用 RNN 生成音樂

在 TensorFlow.org 上檢視 在 Google Colab 中執行 在 GitHub 上檢視原始碼 下載筆記本

本教學課程說明如何使用簡易的循環神經網路 (RNN) 生成音符。您將使用來自 MAESTRO 資料集的鋼琴 MIDI 檔案集合來訓練模型。在給定音符序列的情況下,您的模型將學習預測序列中的下一個音符。您可以透過重複呼叫模型來生成更長的音符序列。

本教學課程包含剖析和建立 MIDI 檔案的完整程式碼。您可以造訪使用 RNN 進行文字生成教學課程,進一步瞭解 RNN 的運作方式。

設定

本教學課程使用 pretty_midi 程式庫來建立和剖析 MIDI 檔案,並使用 pyfluidsynth 在 Colab 中產生音訊播放。

sudo apt install -y fluidsynth
pip install --upgrade pyfluidsynth
pip install pretty_midi
import collections
import datetime
import fluidsynth
import glob
import numpy as np
import pathlib
import pandas as pd
import pretty_midi
import seaborn as sns
import tensorflow as tf

from IPython import display
from matplotlib import pyplot as plt
from typing import Optional
seed = 42
tf.random.set_seed(seed)
np.random.seed(seed)

# Sampling rate for audio playback
_SAMPLING_RATE = 16000

下載 Maestro 資料集

data_dir = pathlib.Path('data/maestro-v2.0.0')
if not data_dir.exists():
  tf.keras.utils.get_file(
      'maestro-v2.0.0-midi.zip',
      origin='https://storage.googleapis.com/magentadata/datasets/maestro/v2.0.0/maestro-v2.0.0-midi.zip',
      extract=True,
      cache_dir='.', cache_subdir='data',
  )

此資料集包含約 1,200 個 MIDI 檔案。

filenames = glob.glob(str(data_dir/'**/*.mid*'))
print('Number of files:', len(filenames))

處理 MIDI 檔案

首先,使用 pretty_midi 剖析單一 MIDI 檔案,並檢查音符的格式。如果您想要下載下方的 MIDI 檔案以在電腦上播放,可以在 Colab 中輸入 files.download(sample_file) 來執行。

sample_file = filenames[1]
print(sample_file)

為範例 MIDI 檔案產生 PrettyMIDI 物件。

pm = pretty_midi.PrettyMIDI(sample_file)

播放範例檔案。播放小工具可能需要幾秒鐘才能載入。

def display_audio(pm: pretty_midi.PrettyMIDI, seconds=30):
  waveform = pm.fluidsynth(fs=_SAMPLING_RATE)
  # Take a sample of the generated waveform to mitigate kernel resets
  waveform_short = waveform[:seconds*_SAMPLING_RATE]
  return display.Audio(waveform_short, rate=_SAMPLING_RATE)
display_audio(pm)

對 MIDI 檔案執行一些檢查。使用了哪些樂器?

print('Number of instruments:', len(pm.instruments))
instrument = pm.instruments[0]
instrument_name = pretty_midi.program_to_instrument_name(instrument.program)
print('Instrument name:', instrument_name)

擷取音符

for i, note in enumerate(instrument.notes[:10]):
  note_name = pretty_midi.note_number_to_name(note.pitch)
  duration = note.end - note.start
  print(f'{i}: pitch={note.pitch}, note_name={note_name},'
        f' duration={duration:.4f}')

您將使用三個變數來表示訓練模型時的音符:pitchstepduration。音高是聲音的感知品質,以 MIDI 音符編號表示。step 是從前一個音符或音軌開始經過的時間。duration 是音符將播放的秒數,是音符結束時間與開始時間之間的差異。

從範例 MIDI 檔案擷取音符。

def midi_to_notes(midi_file: str) -> pd.DataFrame:
  pm = pretty_midi.PrettyMIDI(midi_file)
  instrument = pm.instruments[0]
  notes = collections.defaultdict(list)

  # Sort the notes by start time
  sorted_notes = sorted(instrument.notes, key=lambda note: note.start)
  prev_start = sorted_notes[0].start

  for note in sorted_notes:
    start = note.start
    end = note.end
    notes['pitch'].append(note.pitch)
    notes['start'].append(start)
    notes['end'].append(end)
    notes['step'].append(start - prev_start)
    notes['duration'].append(end - start)
    prev_start = start

  return pd.DataFrame({name: np.array(value) for name, value in notes.items()})
raw_notes = midi_to_notes(sample_file)
raw_notes.head()

音符名稱可能比音高更容易解讀,因此您可以使用以下函式將數值音高值轉換為音符名稱。音符名稱顯示音符類型、升降記號和八度音程編號 (例如 C#4)。

get_note_names = np.vectorize(pretty_midi.note_number_to_name)
sample_note_names = get_note_names(raw_notes['pitch'])
sample_note_names[:10]

為了視覺化樂曲,請繪製音符音高、開始和結束在音軌長度上的變化 (即鋼琴捲簾)。從前 100 個音符開始

def plot_piano_roll(notes: pd.DataFrame, count: Optional[int] = None):
  if count:
    title = f'First {count} notes'
  else:
    title = f'Whole track'
    count = len(notes['pitch'])
  plt.figure(figsize=(20, 4))
  plot_pitch = np.stack([notes['pitch'], notes['pitch']], axis=0)
  plot_start_stop = np.stack([notes['start'], notes['end']], axis=0)
  plt.plot(
      plot_start_stop[:, :count], plot_pitch[:, :count], color="b", marker=".")
  plt.xlabel('Time [s]')
  plt.ylabel('Pitch')
  _ = plt.title(title)
plot_piano_roll(raw_notes, count=100)

繪製整個音軌的音符。

plot_piano_roll(raw_notes)

檢查每個音符變數的分配。

def plot_distributions(notes: pd.DataFrame, drop_percentile=2.5):
  plt.figure(figsize=[15, 5])
  plt.subplot(1, 3, 1)
  sns.histplot(notes, x="pitch", bins=20)

  plt.subplot(1, 3, 2)
  max_step = np.percentile(notes['step'], 100 - drop_percentile)
  sns.histplot(notes, x="step", bins=np.linspace(0, max_step, 21))

  plt.subplot(1, 3, 3)
  max_duration = np.percentile(notes['duration'], 100 - drop_percentile)
  sns.histplot(notes, x="duration", bins=np.linspace(0, max_duration, 21))
plot_distributions(raw_notes)

建立 MIDI 檔案

您可以使用以下函式,從音符清單中生成自己的 MIDI 檔案。

def notes_to_midi(
  notes: pd.DataFrame,
  out_file: str, 
  instrument_name: str,
  velocity: int = 100,  # note loudness
) -> pretty_midi.PrettyMIDI:

  pm = pretty_midi.PrettyMIDI()
  instrument = pretty_midi.Instrument(
      program=pretty_midi.instrument_name_to_program(
          instrument_name))

  prev_start = 0
  for i, note in notes.iterrows():
    start = float(prev_start + note['step'])
    end = float(start + note['duration'])
    note = pretty_midi.Note(
        velocity=velocity,
        pitch=int(note['pitch']),
        start=start,
        end=end,
    )
    instrument.notes.append(note)
    prev_start = start

  pm.instruments.append(instrument)
  pm.write(out_file)
  return pm
example_file = 'example.midi'
example_pm = notes_to_midi(
    raw_notes, out_file=example_file, instrument_name=instrument_name)

播放生成的 MIDI 檔案,看看是否有任何差異。

display_audio(example_pm)

和之前一樣,您可以輸入 files.download(example_file) 來下載和播放此檔案。

建立訓練資料集

透過從 MIDI 檔案擷取音符來建立訓練資料集。您可以從少量檔案開始,稍後再試驗更多檔案。這可能需要幾分鐘時間。

num_files = 5
all_notes = []
for f in filenames[:num_files]:
  notes = midi_to_notes(f)
  all_notes.append(notes)

all_notes = pd.concat(all_notes)
n_notes = len(all_notes)
print('Number of notes parsed:', n_notes)

接下來,從剖析的音符建立 tf.data.Dataset

key_order = ['pitch', 'step', 'duration']
train_notes = np.stack([all_notes[key] for key in key_order], axis=1)
notes_ds = tf.data.Dataset.from_tensor_slices(train_notes)
notes_ds.element_spec

您將以音符序列批次訓練模型。每個範例都將包含音符序列作為輸入特徵,而下一個音符作為標籤。透過這種方式,模型將被訓練來預測序列中的下一個音符。您可以在使用 RNN 進行文字分類中找到描述此過程的圖表 (以及更多詳細資訊)。

您可以使用方便的 window 函式,並將大小設為 seq_length,以這種格式建立特徵和標籤。

def create_sequences(
    dataset: tf.data.Dataset, 
    seq_length: int,
    vocab_size = 128,
) -> tf.data.Dataset:
  """Returns TF Dataset of sequence and label examples."""
  seq_length = seq_length+1

  # Take 1 extra for the labels
  windows = dataset.window(seq_length, shift=1, stride=1,
                              drop_remainder=True)

  # `flat_map` flattens the" dataset of datasets" into a dataset of tensors
  flatten = lambda x: x.batch(seq_length, drop_remainder=True)
  sequences = windows.flat_map(flatten)

  # Normalize note pitch
  def scale_pitch(x):
    x = x/[vocab_size,1.0,1.0]
    return x

  # Split the labels
  def split_labels(sequences):
    inputs = sequences[:-1]
    labels_dense = sequences[-1]
    labels = {key:labels_dense[i] for i,key in enumerate(key_order)}

    return scale_pitch(inputs), labels

  return sequences.map(split_labels, num_parallel_calls=tf.data.AUTOTUNE)

設定每個範例的序列長度。試驗不同的長度 (例如 50、100、150),看看哪一個最適合資料,或使用 超參數調整。詞彙表的大小 (vocab_size) 設定為 128,代表 pretty_midi 支援的所有音高。

seq_length = 25
vocab_size = 128
seq_ds = create_sequences(notes_ds, seq_length, vocab_size)
seq_ds.element_spec

資料集的形狀為 (100,1),表示模型將採用 100 個音符作為輸入,並學習預測作為輸出的下一個音符。

for seq, target in seq_ds.take(1):
  print('sequence shape:', seq.shape)
  print('sequence elements (first 10):', seq[0: 10])
  print()
  print('target:', target)

批次處理範例,並設定資料集的效能。

batch_size = 64
buffer_size = n_notes - seq_length  # the number of items in the dataset
train_ds = (seq_ds
            .shuffle(buffer_size)
            .batch(batch_size, drop_remainder=True)
            .cache()
            .prefetch(tf.data.experimental.AUTOTUNE))
train_ds.element_spec

建立和訓練模型

模型將有三個輸出,每個音符變數各一個。對於 stepduration,您將使用自訂損失函式,此函式以均方誤差為基礎,鼓勵模型輸出非負值。

def mse_with_positive_pressure(y_true: tf.Tensor, y_pred: tf.Tensor):
  mse = (y_true - y_pred) ** 2
  positive_pressure = 10 * tf.maximum(-y_pred, 0.0)
  return tf.reduce_mean(mse + positive_pressure)
input_shape = (seq_length, 3)
learning_rate = 0.005

inputs = tf.keras.Input(input_shape)
x = tf.keras.layers.LSTM(128)(inputs)

outputs = {
  'pitch': tf.keras.layers.Dense(128, name='pitch')(x),
  'step': tf.keras.layers.Dense(1, name='step')(x),
  'duration': tf.keras.layers.Dense(1, name='duration')(x),
}

model = tf.keras.Model(inputs, outputs)

loss = {
      'pitch': tf.keras.losses.SparseCategoricalCrossentropy(
          from_logits=True),
      'step': mse_with_positive_pressure,
      'duration': mse_with_positive_pressure,
}

optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)

model.compile(loss=loss, optimizer=optimizer)

model.summary()

測試 model.evaluate 函式,您可以看到 pitch 損失明顯大於 stepduration 損失。請注意,loss 是透過加總所有其他損失計算得出的總損失,目前以 pitch 損失為主。

losses = model.evaluate(train_ds, return_dict=True)
losses

平衡此問題的一種方法是使用 loss_weights 引數來編譯

model.compile(
    loss=loss,
    loss_weights={
        'pitch': 0.05,
        'step': 1.0,
        'duration':1.0,
    },
    optimizer=optimizer,
)

然後,loss 會變成個別損失的加權總和。

model.evaluate(train_ds, return_dict=True)

訓練模型。

callbacks = [
    tf.keras.callbacks.ModelCheckpoint(
        filepath='./training_checkpoints/ckpt_{epoch}',
        save_weights_only=True),
    tf.keras.callbacks.EarlyStopping(
        monitor='loss',
        patience=5,
        verbose=1,
        restore_best_weights=True),
]
%%time
epochs = 50

history = model.fit(
    train_ds,
    epochs=epochs,
    callbacks=callbacks,
)
plt.plot(history.epoch, history.history['loss'], label='total loss')
plt.show()

生成音符

若要使用模型生成音符,您首先需要提供音符的起始序列。以下函式會從音符序列生成一個音符。

對於音符音高,它會從模型產生的音符 softmax 分配中繪製樣本,而不會僅僅選擇機率最高的音符。總是選擇機率最高的音符會導致生成重複的音符序列。

temperature 參數可用於控制生成音符的隨機性。您可以在使用 RNN 進行文字生成中找到關於溫度的更多詳細資訊。

def predict_next_note(
    notes: np.ndarray, 
    model: tf.keras.Model, 
    temperature: float = 1.0) -> tuple[int, float, float]:
  """Generates a note as a tuple of (pitch, step, duration), using a trained sequence model."""

  assert temperature > 0

  # Add batch dimension
  inputs = tf.expand_dims(notes, 0)

  predictions = model.predict(inputs)
  pitch_logits = predictions['pitch']
  step = predictions['step']
  duration = predictions['duration']

  pitch_logits /= temperature
  pitch = tf.random.categorical(pitch_logits, num_samples=1)
  pitch = tf.squeeze(pitch, axis=-1)
  duration = tf.squeeze(duration, axis=-1)
  step = tf.squeeze(step, axis=-1)

  # `step` and `duration` values should be non-negative
  step = tf.maximum(0, step)
  duration = tf.maximum(0, duration)

  return int(pitch), float(step), float(duration)

現在生成一些音符。您可以在 next_notes 中試驗溫度和起始序列,看看會發生什麼事。

temperature = 2.0
num_predictions = 120

sample_notes = np.stack([raw_notes[key] for key in key_order], axis=1)

# The initial sequence of notes; pitch is normalized similar to training
# sequences
input_notes = (
    sample_notes[:seq_length] / np.array([vocab_size, 1, 1]))

generated_notes = []
prev_start = 0
for _ in range(num_predictions):
  pitch, step, duration = predict_next_note(input_notes, model, temperature)
  start = prev_start + step
  end = start + duration
  input_note = (pitch, step, duration)
  generated_notes.append((*input_note, start, end))
  input_notes = np.delete(input_notes, 0, axis=0)
  input_notes = np.append(input_notes, np.expand_dims(input_note, 0), axis=0)
  prev_start = start

generated_notes = pd.DataFrame(
    generated_notes, columns=(*key_order, 'start', 'end'))
generated_notes.head(10)
out_file = 'output.mid'
out_pm = notes_to_midi(
    generated_notes, out_file=out_file, instrument_name=instrument_name)
display_audio(out_pm)

您也可以透過新增以下兩行程式碼來下載音訊檔案

from google.colab import files
files.download(out_file)

視覺化生成的音符。

plot_piano_roll(generated_notes)

檢查 pitchstepduration 的分配。

plot_distributions(generated_notes)

在上述繪圖中,您會注意到音符變數分配的變化。由於模型的輸出和輸入之間存在回饋迴圈,因此模型傾向於生成相似的輸出序列以減少損失。這與使用 MSE 損失的 stepduration 特別相關。對於 pitch,您可以透過增加 predict_next_note 中的 temperature 來增加隨機性。

後續步驟

本教學課程示範了使用 RNN 從 MIDI 檔案資料集生成音符序列的機制。若要瞭解更多資訊,您可以造訪密切相關的使用 RNN 進行文字生成教學課程,其中包含其他圖表和說明。

使用 GAN 生成音樂的替代方案之一是使用 GAN。基於 GAN 的方法不是生成音訊,而是可以平行生成整個序列。Magenta 團隊在使用 GANSynth 的方法上做了令人印象深刻的工作。您也可以在Magenta 專案網站上找到許多精彩的音樂和藝術專案以及開放原始碼。