课程
本教程基于 Python 中的 TensorFlow 2.0 开发,并配合高层 Keras API(它在 TensorFlow 2.0 中扮演了更重要的角色)。如需进一步了解 TensorFlow 2.0,请参阅 DataCamp 上的 Introduction to TensorFlow in Python。如需全面回顾音乐深度学习相关文献,请参阅 Briot, Hadjerest, and Pachet (2019),本教程将在各处引用该文献。
生成式模型
监督式机器学习模型可分为两类:判别式模型与生成式模型。判别式模型识别决策边界并给出相应分类。生成式模型则会创建某一类别的新样本。
音乐的判别式模型可用于将歌曲划分到不同流派。生成式模型则可能谱写特定流派的歌曲。在本教程中,我们将利用生成式模型来作曲。

音乐表示
在定义并训练生成式模型之前,我们首先需要整理数据集。对音乐而言,数据可用连续或离散形式表示。最常见的连续形式为音频信号,通常以 WAV 文件存储。我们不会在本教程中使用连续形式,但您可在附录中了解更多。常见的离散形式包括 MIDI 文件、钢琴卷帘(pianoroll)和文本。我们将聚焦于 MIDI 文件,并提取两类符号对象:音符与和弦。
音符
音符是声音的符号化表示。就我们的目的而言,音符可由其音高与时值描述。音符的音高与其声波振荡频率相关,单位为赫兹(Hz)。音高更高的音符,其声波每秒振荡次数也更多。音符的时值是其被演奏时持续的时长。
MIDI 文件用 0 到 127 之间的整数表示音符的音高。音符也可用音名与八度数表示。在同一八度内,音高按从低到高的频率顺序如下:
- C
- C#/D♭
- D
- E♭/D#
- E
- F
- F#/G♭
- G
- A♭/G#
- A
- B♭/A#
- B
八度用下标标示,例如 A4 中的 4 或 C7 中的 7。八度越高,对应频率越高。若取任意音高 Xi,则高整整一个八度的 Xi+1 对应的声波频率是 Xi 的两倍。
除了音高,我们还将使用音符的时值。时值是相对值,以全音符时长为标准化单位。最长的音符是“超全音符”,其时长是全音符的 8 倍。最短的音符是二百五十六分音符,其时长为全音符的 1/256。
和弦
和弦是在同一乐器上同时演奏的两个或更多音符的组合。若我们只看单声部音乐(即由单一乐器演奏的音乐),则可假定同一时刻演奏的所有音符属于同一和弦,从而识别和弦。若是复调音乐(两个或更多乐器同时演奏),该假设不成立。
在下面的代码块中,我们安装 music21 并导入 converter 模块来解析 MIDI 文件。我们将加载并解析 Mauro Giuliani 的一段古典吉他曲目,然后应用 .chordify() 方法,在假定同时演奏的音符属于同一和弦的条件下重建谱面中的和弦序列。
!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())
构建出 MIDI 文件中的和弦序列后,我们可以使用 .show() 方法以谱面形式显示。这需要安装可生成并显示乐谱的软件,例如免费提供的 MuseScore。

或者,我们也可以将底层元素以文本形式查看与操作。可使用 .show('text') 方法实现。每行开头括号内显示偏移量,表示元素出现在谱面中的秒数。请注意,出现在第 0 秒的元素中只有一个是和弦,其余为元数据,包括所用乐器、速度、调式与拍号。我们稍后会用到调式,当前可先忽略这些元素。
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>
在元数据之后,其余元素按在歌曲中的出现顺序排列为和弦。例如,<music21.chord.Chord E3 G3> 是由同时演奏 E3 与 G3 形成的和弦;而 <music21.chord.Chord A3> 则是单独演奏的音符 A3。
对于某些模型,我们将同时使用和弦及其时值(演奏持续时间)。我们可以通过调用 .elements 方法并访问谱面中任一和弦的 .duration 属性获取该信息,如下所示。
print(score.elements[10])
<music21.chord.Chord E3 A3>
print(score.elements[10].duration)
<music21.duration.Duration 1.0>
音符与和弦编码
在整个教程中,我们将聚焦能够用音符、和弦及其时值序列描述的离散音乐表示。回忆一下,和弦是音符的组合,而 MIDI 格式允许使用 128 个单独的音符。如果我们将特征集扩展到包含所有二和弦,其规模将增加 128!/[(128-2)!*2!],即 8128 个元素。添加三和弦会再扩展 341,376 个元素,添加四和弦会再扩展 10,668,000 个元素。
前文示例包含了单音符以及二、三、四和弦。若我们对所有特征使用独热编码,就需要在 11,017,632 维的向量上进行预测。因此,在音乐生成问题中,我们需要谨慎选择编码方式。同时也可考虑使用移调或数据增广,这二者都在附录中讨论。
关于编码选择,Briot, Hadjerest, and Pachet (2019) 讨论了生成式音乐模型中常用的四种方案:
- 独热编码(One-Hot Encoding)
- 多热编码(Many-Hot Encoding)
- 多独热编码(Multi-One-Hot Encoding)
- 多-多热编码(Multi-Many-One-Hot Encoding)
独热编码将音符、和弦等音乐符号表示为稀疏向量,在与特定音符或和弦对应的位置上为 1。多热编码用于表示由多个音符组成的和弦,在每个对应音符的位置上置 1。
在下图中,我们比较了在包含音符与二和弦时,多热与独热编码的区别。采用多热编码时,C4 与 D4 用 128 维向量表示,除 C4 与 D4 对应位置为 1 外,其余位置皆为 0。由同时演奏 C4 与 D4 形成的 C4+D4 和弦,则在 C4 与 D4 的位置都置 1。

单音符与二和弦的独热表示由 8256 维向量组成。各向量都是稀疏的,仅在代表某个唯一音符或和弦的位置为 1。需要注意,多热表示要求使用多标签模型,即对一次输出可能预测多个类别。但它能大幅降低输入与输出向量的维度。
文献中的大多数模型仍采用独热编码。不过,Briot, Hadjerest, and Pachet (2019) 指出了若干采用多热编码的音乐生成模型,包括基于受限玻尔兹曼机(RBM)架构的 RBMc、RNN-RBM 与 C-RBM 系统。
多独热与多-多热编码是将独热与多热扩展到复调音乐(即多种乐器同时演奏)场景的泛化形式。我们将在本教程中限制为单声部音乐,不再展开讨论。
数据收集与准备
接下来,我们需要收集可用于训练生成式模型的数据集。我们将使用 Mutopia Project 的数据,其中包含 2124 首公共领域的 MIDI 格式作品。由于网站按乐器分类,我们可以编写一个相对简单的 Python 脚本来抓取所有吉他曲目,用于本教程中的模型训练。如何操作的概览,请参阅附录。
假设我们已将吉他分类下的所有 .mid 文件保存到目录 save_dir。下一步是将每个 .mid 文件以 stream 对象的形式加载到 music21 中。
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)
作为降维策略的一部分,我们将自己限制在 Mauro Giuliani 的作品。可按下面方式修改上述代码块。
# Identify list of MIDI files
songList = os.listdir(save_dir)
songList = [song for song in songList if song.lower().find('giuliani')>-1]
在开始训练之前,我们还需要完成四件事:
- 移除复调音乐,并对剩余的
stream对象应用.chordify()。 - 提取音符、和弦与时值序列。
- 构建将音符、和弦与时值映射为整数(及其逆映射)的字典。
- 将流分割为固定长度的序列,用作模型输入。
先从数据集中移除复调音乐。我们可以按乐器将每个 stream 对象分区,并检查是否只有一个分区——也就是是否仅有一种乐器在演奏。如下所示:
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]
请注意,如果在未移除复调音乐的情况下应用 .chordify()(如下图所示),我们会不小心把不同乐器演奏的音符合并成和弦。

现在需要从每个 stream 中提取音符、和弦与时值。我们先定义三个空列表:originalChords、originalDurations、originalKeys。接着遍历流列表。对每个流,我们将识别并保存所有音符、和弦与时值,以及该曲目的调式。
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))
接下来我们将进一步降维,仅保留调号为 C 大调 的作品(它在我们的数据集中最常见)。此外,在本教程余下部分中,我们将用“和弦”一词同时指代和弦与音符。
# 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')]
下一步是识别唯一的和弦与时值集合,并构建将它们映射为整数的字典。打印唯一音符与和弦的数量可见,通过上述降维步骤,数量已降低到一个可控的 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
在训练模型并生成预测后,我们需要将整数预测映射回音符、和弦与时值。如下反转 chordToInt 与 durationToInt 字典。
# Invert chord and duration dictionaries
intToChord = {i: c for c, i in chordToInt.items()}
intToDuration = {i: c for c, i in durationToInt.items()}
最后,我们可以定义训练序列,它由连续 32 个音符与和弦及其对应时值组成。我们先为自编码器类模型构造,其中特征与目标相同。
# 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])
音乐生成
本节我们将利用已整理与准备的数据集来生成音乐。我们将依次考虑三种不同的音乐生成模型,从最简单的开始:
- 自编码器(Autoencoder)
- 变分自编码器(VAE)
- 长短期记忆网络(LSTM)
自编码器
自编码器由两个网络构成,垂直堆叠并通过潜在向量相连。自编码器的输入首先传入编码器模型,通常由一个或多个全连接层构成。编码器模型的最后一层称为潜在向量。该瓶颈迫使编码器提取的特征压缩为少量潜在特征。
潜在向量连接到解码器模型,对数据进行上采样或解压缩。解码器各层的神经元数逐层增加。最终输出层与编码器输入层的维度相同。
与尝试识别决策边界的判别式模型不同,自编码器被训练来尽可能准确地重构输入数据——在本例中是一首曲子——同时受限于潜在向量的规模。因此,自编码器直接将输入作为目标。
我们将用自编码器构建第一个音乐生成模型。为尽可能简化,我们采用 Briot, Hadjerest, and Pachet (2019) 中作为 DeepBach 简化版提出的 MiniBach 架构。另一个自编码器音乐生成示例可参见 DeepHear 系统。
按我们的构造,输入数据由表示音符与和弦的独热向量组成。我们暂时忽略时值。按照 MiniBach 架构,我们先将输入向量展平。
# 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)
接着定义样本数 nSamples、和弦与音符数量 nChords,以及输入维度 inputDim。同时将潜在特征数 latentDim 设为 2。较少的潜在维度会降低性能,但便于直观展示潜在空间。
# 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
现在定义模型架构,从编码器输入层 encoderInput 与解码器输入层 latent 开始。注意 latent 层同时也是编码器网络的输出。然后定义连接输入与潜在向量的全连接层 encoded,以及连接潜在向量与输出向量的全连接层 decoded。
最后定义解码器模型 decoder 与自编码器 autoencoder。注意 .Model() 有 inputs 与 outputs 参数。对 decoder 模型,我们将 latent 向量作为输入,decoded 网络作为 output。对完整自编码器,我们使用 encoderInput 作为输入,并将编码器网络输出 encoded 传入 decoder 模型。
# 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))
下图展示了通用自编码器的架构。网络上半部分为编码器,下半部分为解码器,它们通过潜在向量连接。

在定义好 decoder 与 autoencoder 的架构后,剩余步骤就是编译模型并使用 .fit() 训练。这里有两点需要注意:
- 输入值是 0 与 1,预测值是 0 到 1 的实数。我们可使用
binary_crossentropy损失。 - 输入与目标值相同:
trainChordsFlat。
# Compile autoencoder model
autoencoder.compile(loss = 'binary_crossentropy', learning_rate = 0.01, optimizer='rmsprop')
# Train autoencoder
autoencoder.fit(trainChordsFlat, trainChordsFlat, epochs = 500)
最后一步是生成音乐。由于我们将序列长度设为 32,autoencoder 会接收 32 个和弦与音符作为输入,输出一个固定长度的“歌曲”,由 32 个和弦与音符的序列构成。自编码器被训练为生成与输入高度相似的输出;但我们的目标是生成新音乐。为此,我们将随机生成的潜在向量传入作为自编码器子网络定义的 decoder 模型。
# Generate chords from randomly generated latent vector
generatedChords = decoder(np.random.normal(size=(1,latentDim))).numpy().reshape(nChords, sequenceLength).argmax(0)
注意,我们将自编码器的输出重塑为与原始输入相同的形状,并在第一维上取 argmax()。这会返回与和弦对应的整数值,接下来我们将其识别出来。
# Identify chord sequence from integer sequence
chordSequence = [intToChord[c] for c in generatedChords]
最后,我们可使用 music21 创建 stream 对象,将乐器设为吉他,追加模型生成的和弦序列,并导出为 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')
下方的动图展示了自编码器训练过程的一部分。每一步生成的谱面是与某一特定潜在状态向量对应的重构结果。每一帧代表 5 个训练 epoch。

我们也可以在训练过程中可视化潜在空间的变化,如下图所示。可见潜在空间狭窄并在各 epoch 间漂移。这会使得很难抽取到高质量的随机潜在状态,且在采用更高维潜在向量时问题更为明显。下一节我们将展示如何用变分自编码器(VAE)解决该问题。

最后,我们可以查看自编码器的音频输出。首先给出原始歌曲的和弦序列(从训练集中随机抽取)。随后我们使用与该原始歌曲和弦序列对应的潜在向量从自编码器生成一段和弦序列,即下方的歌曲。
示例:原始歌曲
示例:生成歌曲
变分自编码器
尽管自编码器适合于去噪、压缩与解压等任务,但它们并不擅长生成式建模。由 Kingma 和 Welling (2017) 引入的变分自编码器(VAE)旨在克服自编码器作为生成式模型的弱点,也是最常用的音乐生成方法之一。Briot, Hadjerest, and Pachet (2019) 指出了四个基于 VAE 的音乐生成系统:MusicVAE、VRAE、VRASH 与 GSLR-VAE。它们相较自编码器有两方面改进:
- VAE 不再将每组输入映射到潜在空间的一个点,而是映射到一个由均值与方差表征的正态分布。
- VAE 不允许潜在空间具有任意的均值与方差,而是将均值与方差的自然对数约束为接近 0。
实现第一点需要将自编码器中的潜在状态向量替换为三层:
- 均值层
- (对数)方差层
- 采样层
每组输入映射到一个均值与方差的自然对数,这就足以刻画一个正态分布。模型随后在采样层中从该分布随机抽样。
下面的代码块定义了一个用于创建采样层的函数。它接收均值与对数方差作为输入,然后使用 tensorflow 的 random 子模块从标准正态分布(均值与对数方差均为 0)中抽取张量 epsilon。接着我们要将这些抽样转换为由 mean 与 logVar 参数所定义分布的抽样。为此,我们先用对应分布的标准差缩放 epsilon,可通过将 logVar 除以 2 并指数变换得到标准差,然后再加上分布均值 mean。
# 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
现在我们可修改自编码器模型以纳入 mean、logVar 与 sampling 层。注意 encoded 层现在接收两个输入,encoder 层有三个输出。
# 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))
与自编码器模型一样,我们的潜在状态是 encoded 层的输出。但有一处重要区别:我们不再将每组输入映射到一个单一潜在状态,而是使用 sampling 层将其映射为从参数为 mean 与 logVar 的正态分布中抽取的随机值。这意味着同一输入将对应一个潜在状态的分布。
最后一步是调整损失函数,以约束模型选择的 mean 与 logVar 值。具体而言,我们将计算由 mean 与 logVar 层定义的每个分布与标准正态分布(均值与对数方差为 0)之间的Kullback-Leibler(KL)散度。均值与对数方差偏离 0 越多,KL 散度损失分量越大。
一个自然的问题是:为何要将每个分布推近标准正态?原因在于我们希望对潜在状态进行采样并用其生成音乐。若在损失函数中加入 KL 散度惩罚,就可以用来自标准正态分布的独立抽样来得到很可能生成高质量音乐的潜在状态。
下面的代码块展示了如何修改损失函数以纳入 KL 散度项。损失函数的第一部分是二元交叉熵重构损失,在 vae 模型的输入与输出来计算,这与自编码器相同,之所以称为“重构损失”是因为它惩罚未能重构原曲。KL 项是潜在空间分布与标准正态分布之间 KL 散度的平均值。将二者相加得到总损失 vaeLoss,添加到模型后进行编译并训练。
# 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)
上一节我们展示了自编码器的潜在空间行为不佳。VAE 的部分目的就是在生成式设置中克服这一设计缺陷。下图为 VAE 的潜在空间。可以看到两个特征都有显著离散分布,且都以 0 为中心,并不会在各训练 epoch 间漂移。这将更易于随机采样到高质量的潜在状态。

最后,我们进行音乐生成。从标准正态分布抽取随机潜在向量,传入解码器,重塑输出,并对每列取 argmax。这将返回一个长度为 32 的整数序列,对应音符与和弦。接着用 intToChord 字典识别这些音符与和弦。然后定义 stream 对象,追加吉他为乐器,将音符与和弦序列加入 stream 并导出为 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')
下方的三首歌均由模型生成。前两首基于训练集中和弦序列所对应的潜在状态。最后一首是歌曲 #1 与歌曲 #2 的潜在状态的线性组合。由于 VAE 使潜在空间更“规整”,我们可以相当有把握地认为,潜在状态的线性组合也会产生高质量的潜在状态。
示例:生成歌曲 #1
示例:生成歌曲 #2
示例:歌曲 #1 与 #2 的线性组合
从自编码器与 VAE 的音乐中我们能观察到一个问题:旋律性不强。我们可以通过增加模型层数与扩大训练集改善这一点。或者,我们可以使用专门处理序列数据的模型,如下一节所示。
长短期记忆网络(LSTM)
最后一种模型是长短期记忆网络(LSTM),它是一类经改良以避免梯度消失问题的循环神经网络(RNN)。Briot, Hadjerest, and Pachet (2019) 发现循环模型是音乐生成中最常用的方法。他们指出有 20 多个依赖 RNN 或 LSTM 的音乐生成系统,包括 BachBot、DeepBach、Performance-RNN 与 Hexahedria。
与基于自编码器的模型相比,使用 LSTM 有两点优势:
- 它们专为处理序列数据而设计。
- 它们可以生成任意长度的音乐序列。
与使用输入作为目标的自编码器与 VAE 不同,基于 LSTM 的生成式音乐模型训练目标是预测序列中的下一个音符或和弦。因此,我们需要重构训练与目标集,如下所示。鉴于 LSTM 会减少需要训练的参数数量,我们也将把时值加入模型。
# 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])
请注意,trainChords 与 trainDurations 的定义与教程前文相同。我们仅新增了 targetChords 与 targetDurations,它们分别是训练序列之后的那个和弦与时值。接下来设定样本数、和弦与时值数量,以及输入维度。我们还将为和弦与时值分别使用独立的嵌入层。
# 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
下一步定义模型架构。我们将使用具有两个输入与两个输出的模型,从而同时预测和弦与时值。我们先定义输入层 chordInput 与 durationInput,并将它们传入嵌入层 chordEmbedding 与 durationEmbedding。嵌入层是查找表,为每个和弦或时值关联一个 64 维向量。嵌入能让我们用稠密表示替代稀疏的独热表示,使得相关音符与时值在向量空间中更接近。
两个嵌入层的输出在 mergeLayer 中拼接。随后我们将该层输出传入两个 LSTM 层。第一层将 return_sequences 参数设为 True。如果保持默认的 False,LSTM 单元只返回最终状态,不返回中间的序列状态。最新研究表明,利用中间状态可以提升性能,这通常与注意力层配合使用。最后,我们将 LSTM 层的输出传入全连接层,再分别传入两个输出层:chordOutput 与 durationOutput。接着通过指定两个输入与两个输出层来定义 keras 函数式 API 模型。
# 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])
定义好模型后即可编译并开始训练。我们在此尽可能保持简洁,但在许多应用中,加入正则化层以及额外的全连接与 LSTM 层会更合适。请注意,模型具有两个输入与两个输出层,如下图所示。关于多输入多输出模型的更多内容,您可参阅 Introduction to TensorFlow in Python。

# Compile the model
lstm.compile(loss='categorical_crossentropy', optimizer='rmsprop')
# Train the model
lstm.fit([trainChords, trainDurations], [targetChords, targetDurations],
epochs=500, batch_size=64)
训练完成后,我们可以使用 LSTM 模型生成新曲。方法是先输入初始的 32 个和弦与时值序列,以获得第一次预测;然后将预测结果追加到和弦与时值序列末尾,使我们可以基于更新序列的最后 32 个和弦与时值再次进行预测。LSTM 的一大好处是我们可以无限迭代,从而生成任意长度的歌曲。
# 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
最后一步是使用 music21 将生成的音乐导出为 MIDI 文件。请注意,这段代码与我们在自编码器模型中使用的几乎相同,但有一个重要区别:我们现在向 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')
在自编码器与 VAE 模型中,我们难以生成旋律,且只能输出固定长度的歌曲。使用 LSTM 模型,我们可以通过迭代预测生成任意长度的输出序列(如上面的代码所示),并且相对更容易生成更好的旋律。下面的示例将展示这两点,每个示例都以训练集中某个样本的初始和弦与时值序列作为种子。
示例:生成歌曲 #1
示例:生成歌曲 #2
示例:生成歌曲 #3
其他议题
数据
音乐机器学习模型开发的一大挑战在于数据集的可获得性,因为音乐通常无法自由合法地下载。这事实上阻碍了学界在实践标准数据集上的协作,例如图像分类中的 MNIST。尽管如此,仍有一些项目为大量歌曲提供了原始音频、符号谱记或衍生的音乐特征。我们在下方提供部分列表。
- Mutopia Project。公共领域与创用 CC 的音乐档案,包含 MIDI、PDF 与 LilyPond 格式。目前收录 2000 多首,按乐器、作曲家、风格与合集组织。
- The Million Song Dataset。包含超过 300GB 的流行音乐曲目的音频特征与元数据,但不含音轨。
- Free Music Archive。包含 100,000 多首可自由合法下载的曲目的音频、流派标签与特征。
采样 vs. 确定性
使用 LSTM 生成音乐时,我们采用了确定性的和弦与时值选择。模型为 267 个和弦与 9 个时值预测概率,我们选择概率最高者。类似地,在自编码器与 VAE 模型中,我们选择输出值最高的和弦。
另一种方法是从输出分布中进行采样,而不是选择概率最高的和弦与时值。这会使音乐生成过程变为随机而非确定性。这意味着使用同一潜在向量的自编码器或 VAE 模型每次运行都会生成不同的歌曲。同理,向 LSTM 模型输入同一序列也可生成不同的歌曲。
下面的代码块展示了如何通过修改 predictChords() 函数,将和弦与时值的生成改为随机方式。注意我们用基于概率的随机抽样替换了 argmax() 操作。
# 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)
关于随机与确定性方法在音乐生成中的优缺点之详尽讨论,参见 Briot, Hadjerest, and Pachet (2019) 的第 6.6 节。
嵌入
在训练 LSTM 模型时,我们使用了嵌入层,将每个输入和弦与时值映射为 64 维稠密向量。使用嵌入层可避免稀疏的高维向量输入,转而使用低维表示,从而识别不同和弦之间的关系。
在许多情况下,训练一个嵌入层并不合适,因为音乐生成问题中我们往往没有足够大的训练集。与文本分类和生成问题类似,使用预训练嵌入或通过无监督学习得到的嵌入,可以在原本不可行的情况下训练模型。
有关如何用无监督学习构建嵌入的综述,参见 Chuan, Agres, and Herremans (2018)。
生成式对抗网络
我们讨论了用于音乐生成的自编码器、VAE 与 LSTM 模型。另一类在其他领域的生成式建模中颇为成功的方法是生成式对抗网络(GAN)。GAN 结合了判别式网络与生成式网络。生成器创建样本(例如我们的 LSTM 与自编码器生成的和弦序列),这些样本与真实样本(即真实歌曲的和弦序列)一起组成训练集。判别器尝试将样本分类为真实或由生成器生成。生成器则尝试欺骗判别器,使后者将其生成的样本误判为真实。模型训练直至达到稳定的进化均衡,判别器与生成器都无法进一步取得优势。
若您有兴趣将 GAN 应用于音乐生成任务,可参阅以下资源:
-
MuseGAN 致力于使用 GAN 生成复调音乐,包含预训练模型与 174,154 首已清洗与预处理、以 pianoroll 格式存储的歌曲。
-
Magenta 是一个面向生成式建模在创意任务(含音乐创作)中应用的开源项目。您可以通过 Colab 笔记本探索其工具集。
资源
如欲进一步学习 TensorFlow 2.0,请参阅 DataCamp 上的 Introduction to TensorFlow in Python。关于音乐生成与深度学习方法的最新且全面的综述,请参阅 Briot, Hadjerest, and Pachet (2019)。更多使用深度学习生成音乐的教程,参见 1、2、3、4,以及对生成式模型进行系统论述的教材 5。
附录
连续表示
WAV 文件是最常见的连续表示形式,因为它包含未压缩的原始音频信号。这与 mp3 等格式不同,后者通过丢弃部分音频信息来压缩文件体积。
以下代码块展示了如何从包含莫扎特作品的 WAV 文件中提取采样率 rate 与原始音频信号 signal。采样率是每秒对音频信号采样的次数。在我们的例子中,该值为 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))
原始音频信号 signal 是一个 S x C 的张量,其中 S 为采样数,C 为声道数。为简化起见,我们考虑 C = 1 的情形。下图展示了 signal 的 3 秒区间。

总体而言,使用原始音频信号训练模型要比使用离散表示困难得多。一项近期综述考察了文献中 32 种不同的音乐生成方法,其中仅有三种使用连续表示:
移调
移调既可用于降维,也可用于数据增广。它的做法是将每个音符的音高按固定音程偏移。例如,若某曲目中 C3 后接 D3,而我们应用整体上移两个音的移调,则 C3 变为 D3,D3 变为 E3。使用移调的模型可参见 Lim, Rhyu, and Lee (2018) 与 Sturm 等 (2016)。
若将移调用于降维,我们的目标是通过移调来最小化数据集中唯一音符与和弦的总数量。由于音乐通常围绕某些听起来更和谐的音群(称为调)写作,我们可以将数据集中的每首歌移调到相同的调。
这次我们将从 music21 导入三个子模块:
converter:用于加载 MIDI 文件并将其转换为stream对象。pitch:用于创建目标音高。interval:用于计算原始调与目标调之间的音程距离。
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
我们先对 score 应用 .analyze('key') 方法,识别 stream 对象的调式为 C 大调。接着计算 C 大调与目标调 F 大调之间的音程距离。注意通过 .tonic 访问 key 对象的主音;此外,我们用 pitch.Pitch('F') 创建目标音高,并将输出赋给 keyInterval,它表示原调与目标调之间的距离。
最后,使用 .transpose() 并传入 keyInterval 进行移调。打印新的调式可确认为 F 大调。
八度移位
如果希望进一步处理,还可以将八度上移或下移。例如,不是将 C 大调移为 F 大调,而是将 C 大调移为更低或更高八度的 C 大调。若对每首歌都定位到同一组八度范围,我们就能让所有音符与和弦更紧密聚集,降低问题维度并简化训练过程。在上方代码中,我们可以通过指定音高的八度实现,如将 pitch.Pitch('F') 改为 pitch.Pitch('F3')。
调与八度不变性
与降维相对的自然选择是数据增广。即不减少可能的输入与输出集合,而是通过修改现有数据来扩充数据集。我们同样可以用调与八度移位来实现,但不是把所有歌曲移为同一调并聚集在相邻八度,而是将所有歌曲移到所有调与所有八度。这将使我们得到对调与八度不变的模型。关于移调用于这两种不同目的的讨论,参见 Briot, Hadjerest, and Pachet (2019)。
数据收集
我们将从 Project Mutopia 收集用于训练的 MIDI 文件。在进行任何抓取之前,先检查 robots 文件(https://www.mutopiaproject.org/robots.txt),以确保遵守网站对自动化交互的要求。在本教程撰写时,robots 文件包含以下文本。User-agent: * 表示以下规则适用于所有用户;Disallow: 后无内容,表示抓取不受限制。
# Allow crawling of all content
User-agent: *
Disallow:
我们将首先从 urllib.request 导入 urlopen 和 urlretrieve,用于向吉他分类的各页面发送 GET 请求并下载页面中链接的每个 MIDI 文件。导入 BeautifulSoup 以解析 urlopen 返回的 HTML,从而识别 MIDI 链接。最后使用 time 模块在每次下载之间暂停 10 秒,避免给网站资源带来压力。

主循环的每一步都会检查页面上的链接数 linkCount 是否大于 0。若大于 0,就使用字符串 url0、url1 与歌曲编号 songNumber 构造下一个页面的 URL(url)。然后打开该 URL,解析 HTML,找到页面上的所有链接,将 linkCount 重置为 0,并遍历链接列表。若某个链接包含子串 .mid,则其指向一个 MIDI 文件,将使用 urlretrieve() 下载到 save_dir 指定的目录。最后我们将 songNumber 增加 10,因为每个页面包含 10 首歌曲。重复此过程,直到遇到链接数少于 10 的页面。
!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)
如需进一步了解抓取,请参阅 DataCamp 的 Web Scraping with Python 课程与 Web Scraping using Python 教程。
