本文概述
本教程是围绕Python中的TensorFlow 2.0和高级Keras API开发的, 该高级Keras API在TensorFlow 2.0中起到了增强的作用。对于那些想进一步了解TensorFlow 2.0的人, 请参阅srcmini上的Python中的TensorFlow简介。有关音乐文学深度学习的详尽概述, 请参见Briot, Hadjerest和Pachet(2019), 我们将在本教程中通篇参考。
生成模型
监督机器学习模型可以分为两类:判别模型和生成模型。判别模型确定决策边界并产生相应的分类。生成模型创建类的新实例。
音乐的判别模型可用于将歌曲分类为不同流派。生成模型可能会构成特定类型的歌曲。在本教程中, 我们将使用生成模型来创作音乐。
区分性与生成性
音乐表现
在定义和训练生成模型之前, 我们必须首先组装一个数据集。对于音乐, 可以使用连续或离散形式表示数据。最常见的连续形式是音频信号, 通常存储为WAV文件。在本教程中, 我们将不使用连续表格, 但是你可以在附录中阅读有关连续表格的更多信息。常见的离散形式包括乐器数字接口(MIDI)文件, 钢琴卷和文本。我们将专注于MIDI文件, 并将提取两种类型的符号对象:音符和和弦。
笔记
音符是声音的象征性表示。就我们的目的而言, 音符可以用音高和持续时间来描述。音符的音高与其声波的振荡频率有关, 以赫兹(Hz)为单位。音高较高的音符具有每秒振荡更多的声波。音符的持续时间是指该音符的演奏时间长度。
MIDI文件表示音符的音高, 其整数在0到127之间。音符也可以用音高字母和八度数字表示。在相同的八度音程中, 音调从最低频率到最高频率如下:
- C
- C#/ D♭
- d
- E♭/ D#
- 和
- F
- F#/ G♭
- G
- A♭/ G#
- 一个
- B♭/ A#
- 乙
八度由下标指示, 例如A4中的4或C7中的7。较高的八度音阶对应较高的频率。如果我们采用任意的音高Xi, 则音高Xi + 1(正好高一个八度音阶)代表的声波的频率是Xi的两倍。
除了音符的音高, 我们还将利用其持续时间。持续时间是一个相对值, 通过整个音符的长度进行归一化。最长的音符是”大”音符, 是整个音符的八倍。最短的音符是256分音符, 是整个音符长度的1/256。
和弦
和弦是在同一乐器上同时演奏的两个或多个音符的组合。如果我们查看单声道音乐(即在单个乐器上演奏的音乐), 则可以通过假定在完全相同的时间演奏的所有音符属于同一和弦的一部分来识别和弦。如果我们拥有由两个或多个同时演奏的乐器组成的复音音乐, 则此假设无效。
在下面的代码块中, 我们安装music21, 然后导入转换器模块, 该模块将用于解析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, 该软件可免费获得。
从MIDI生成的乐谱
另外, 我们可能希望检查和操作作为文本的基本元素。我们可以使用.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和Pachet(2019)讨论了音乐生成模型中常用的四个不同选项:
- 一站式编码
- 多热编码
- 多一热编码
- 多对一热编码
一键热编码将音乐符号(例如音符和和弦)表示为稀疏矢量, 并且在对应于特定音符或和弦的位置带有一个。多次热编码通过将一个和弦放置在与每个音符相对应的位置来表示由多个音符组成的和弦。
在下图中, 我们比较了有伴音和两音和弦的情况下的多热和单热编码。通过多次编码, 音符C4和D4由具有128个元素的矢量表示。两个向量在与C4和D4元素相对应的位置(包含1的位置)以外的所有位置均具有零。 C4 + D4和弦是同时演奏音符C4和D4的产物, 它是通过在C4和D4位置同时放置一个音符来构造的。
音符和弦编码
单音符和两音符和弦的一键表示由8256个元素的向量组成。在每种情况下, 向量都是稀疏的, 并且在对应于唯一音符或和弦的位置包含1。注意, 多热点表示需要使用多标签模型, 其中可以为给定输出预测多个类别。但是, 它确实大大减小了输入和输出矢量的大小。
文献中的大多数模型仍然使用单热编码。但是, Briot, Hadjerest和Pachet(2019)确定了几种利用许多热门编码的音乐生成模型, 包括RBMc, RNN-RBM和C-RBM系统, 它们都基于受限的Boltzmann机器(RBM )架构。
多一热编码和多一热编码是一热和多热编码的通用形式, 适用于我们拥有复音音乐的情况-也就是说, 多个乐器同时演奏。我们将限制自己使用单声道音乐, 并且不会在本教程中进一步讨论这些选项。
数据收集与准备
下一步是收集可用于训练生成模型的数据集。我们将利用Mutopia项目中的数据, 该项目包含2124首MIDI格式的音乐, 属于公共领域。由于网站按乐器划分为多个部分, 因此我们可以编写一个相对简单的Python脚本来刮所有吉他片段, 在本教程中我们将使用它们来训练模型。有关如何执行此操作的概述, 请参见附录。
假设我们已经将吉他部分中的所有.mid文件保存到了目录save_dir。下一步是将每个.mid文件作为流对象加载到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]
在开始培训之前, 我们需要做另外四件事:
- 删除和弦音乐, 然后将.chordify()应用于其余的流对象。
- 提取音符, 和弦和持续时间的序列。
- 构造一个映射, 将音符, 和弦和时长转换为整数, 反之亦然。
- 将流分成固定长度的序列, 可用作模型的输入。
让我们从数据集中删除和弦音乐开始。我们可以通过按乐器对每个流对象进行分区, 然后检查是否只有一个分区(即是否仅在演奏一种乐器)来做到这一点。我们将在下面的代码块中执行此操作。
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()(如下图所示)而没有消除和弦音乐, 我们将不小心将不同乐器演奏的音符合并为和弦。
将音符转换为和弦
现在, 我们需要从每个流中提取音符, 和弦和时长。我们首先定义三个空列表: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])
音乐产生
在本节中, 我们将利用我们组装并准备生成音乐的数据集。我们将考虑最简单的三种音乐生成模型:
- 自动编码器
- 可变自动编码器(UAE)
- 长短期记忆模型(LSTM)
汽车编码器
自动编码器由两个网络组成, 这两个网络垂直堆叠, 并由潜在矢量连接。首先将自动编码器的输入传递到编码器模型, 该模型通常由一个或多个密集层组成。编码器模型中的最后一层称为潜矢量。这是一个瓶颈, 迫使在编码器中提取的特征被压缩为少量的潜在特征。
潜矢量连接到解码器模型, 该模型对数据进行上采样或解压缩。每个连续解码器层中的节点数量增加。最终输出层与编码器模型的输入层具有相同的尺寸。
与试图确定决策边界的判别模型相反, 训练自动编码器以尽可能准确地重建输入数据(在这种情况下为歌曲), 但要受潜在矢量大小的约束。因此, 自动编码器仅将输入用作目标。
我们将使用自动编码器来构建我们的第一个音乐生成模型。为了使事情尽可能简单, 我们将采用Briot, Hadjerest和Pachet(2019)中引入的MiniBach模型的体系结构, 以简化DeepBach模型。有关音乐生成的自动编码器模型的另一个示例, 请参见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开始。请注意, 潜在层也是编码器网络的输出。接下来, 我们定义一个密集层(已编码), 它将输入连接到潜在向量, 而另一个密集层(已解码), 将潜在向量与输出向量连接。
最后, 我们定义解码器模型解码器, 以及我们的自动编码器自动编码器。请注意, .Model()具有输入和输出参数。对于解码器模型, 我们将潜在向量作为输入, 将解码后的网络作为输出。对于完整的自动编码器, 我们使用encodedInput作为输入, 并传递解码器模型, 该模型使用编码器网络的输出进行评估, 并作为输入。
# 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))
下图显示了通用自动编码器的体系结构。网络的上半部分是编码器, 下半部分是解码器。它们通过潜矢量连接。
自动编码器网络架构
使用为解码器和自动编码器定义的体系结构, 剩下的唯一步骤是编译模型, 然后使用.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, 因此自动编码器将采用32个和弦和音符作为输入, 并生成由32个和弦和音符序列组成的固定长度的”歌曲”。自动编码器经过训练可以生成与输入高度相似的输出。但是, 我们的目标是产生新的音乐。为此, 我们将随机生成的潜在矢量传递给解码器模型, 该模型被定义为自动编码器模型的子网。
# 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创建一个流对象, 将乐器设置为吉他, 附加我们的模型生成的和弦序列, 然后将所有内容导出为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')
下面的gif显示了自动编码器训练过程的一部分。在每个步骤中生成的分数是与特定潜在状态向量关联的重构。每个帧代表5个训练时期。
自动编码器:音符和和弦序列的演变
我们还可以在训练过程中可视化潜在空间中的运动, 这在下面的gif中进行。请注意, 潜在空间很窄, 并且会随着训练时间的推移而漂移。这将使绘制高质量的随机潜在状态变得困难, 并且当我们使用更高维度的潜在矢量时, 将证明存在很大问题。在下一节中, 我们将展示如何使用变分自动编码器(VAE)解决此问题。
自动编码器:潜在空间演化
最后, 我们可能会检查自动编码器的音频输出。首先给出了原始歌曲的和弦序列, 该序列是从我们的训练集中随机抽取的。然后, 我们使用与原始歌曲中和弦序列相关联的潜在矢量, 从自动编码器中生成一个和弦序列, 从而产生其下方的歌曲。
示例:原始歌曲
示例:生成的歌曲
可变自动编码器
尽管自动编码器非常适合去噪, 压缩和解压缩任务, 但它们不能像生成模型那样很好地执行。 Kingma和Welling(2017)推出的变体自动编码器(VAE)旨在克服自动编码器作为生成模型的弱点, 并且是音乐生成最常用的方法之一。 Briot, Hadjerest和Pachet(2019)确定了基于VAE构建的四种不同的音乐生成系统:MusicVAE, VRAE, VRASH和GSLR-VAE。它们在两个方面对自动编码器进行了改进:
- 可变自动编码器不是将每组输入映射到潜在空间中的点, 而是将每组输入映射到以均值和方差为特征的正态分布。
- 可变自动编码器不让潜在空间具有任何均值和方差, 而是强制均值和方差的自然对数接近于0。
我们可以通过将自动编码器中的潜在状态向量替换为三层来进行第一个更改:
- 平均层
- (对数)方差层
- 采样层
每组输入映射到单个均值和方差的自然对数, 这足以表征正态分布。然后, 该模型从采样层的分布中随机抽取点。
下面的代码块定义了可用于创建采样层的功能。它采用均值和对数方差作为输入。然后, 它使用张量流的随机子模块从均值和对数方差均为0的标准正态分布中绘制点张量epsilon。接下来, 我们要将这些绘制转换为具有均值特征的分布中的绘制和logVar参数。为此, 我们需要先按相关分布的标准偏差缩放绘图epsilon。我们可以通过将logVar除以2然后取幂来计算这些标准差。然后, 我们添加分布均值。
# 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
现在, 我们可以修改自动编码器模型, 以合并均值, logVar和采样层。注意, 编码层现在有两个输入, 编码器层有三个输出。
# 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))
与自动编码器模型一样, 我们的潜在状态是编码层的输出。但是, 有一个重要的区别:我们不是使用每层输入映射到单个潜在状态, 而是使用采样层将它们映射到从均值和logVar参数的正态分布中得出的随机值。这意味着相同的输入将与潜在状态的分布相关联。
最后一步是调整损失函数, 以将约束放在模型的均值和logVar值的选择上。尤其是, 我们将计算由均值和logVar层定义的每个分布与标准正态分布之间的Kullback-Leibler(KL)散度, 均值和对数方差为0。从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为中心, 并且不会在训练时期内漂移。这将使对高质量的潜在状态进行随机采样变得容易得多。
VAE:潜在空间演化
最后, 我们转向音乐创作。我们从标准正态分布中绘制一个随机潜矢量, 将其传递给解码器, 对输出进行整形, 然后获取每列的argmax。这将返回一个32个整数的序列, 分别对应于音符和和弦, 我们接下来使用intToChord词典进行识别。然后, 我们定义一个流对象, 将吉他作为乐器, 将音符和和弦序列添加到流中, 然后将其导出为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和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层将是有意义的。请注意, 模型具有两个输入层和两个输出层, 如下图所示。你可以在Python TensorFlow简介中了解有关具有多个输入和输出的模型的更多信息。
LSTM模型架构
# 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文件。请注意, 该代码与用于基于自动编码器的模型的代码几乎相同, 但是有一个重要的区别:我们现在将和弦和时长都附加到流对象上。
# 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项目。 MIDI, PDF和LilyPond格式的公共领域和知识共享音乐的档案。它目前包含超过2000首歌曲, 并按乐器, 作曲家, 风格和收藏进行组织。
- 百万首歌曲数据集。包含音频功能和元数据, 用于价值超过300gb的流行音乐曲目, 但没有音频曲目。
- 免费音乐档案。包含超过100, 000个曲目的音频, 流派标签和功能, 可免费合法下载。
抽样与确定性
当我们使用LSTM模型生成音乐时, 我们使用确定性方法来选择和弦和时长。该模型预测了267个和弦和9个时长中的每一个的概率, 并且我们选择了相关概率最高的和弦和时长。同样, 对于自动编码器和变体自动编码器模型, 我们选择了具有最高关联输出值的和弦。
此方法的一种替代方法是从输出的分布中采样, 而不是选择概率最高的和弦和持续时间。这将使音乐生成过程是随机的, 而不是确定的。这意味着具有相同潜在矢量的自动编码器或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和Pachet(2019)的6.6节。
嵌入
训练LSTM模型时, 我们使用了一层嵌入, 将每个输入和弦和持续时间映射到一个密集的64元素矢量。使用嵌入层可以避免使用稀疏的高维向量作为输入。取而代之的是, 我们使用低维表示法, 使我们能够识别不同和弦之间的关系。
在许多情况下, 训练嵌入层是没有意义的。这是因为在处理音乐生成问题时, 我们通常没有足够大的训练集。使用预训练的嵌入或使用无监督学习生成的嵌入-就像我们经常在处理文本分类和生成问题时所做的那样-将使我们能够在原本不可行的情况下训练模型。
有关如何使用无监督学习方法构造嵌入的概述, 请参见Chuan, Agres和Herremans(2018)。
生成对抗网络
我们讨论了自动编码器, VAE和LSTM模型作为音乐生成的选项。事实证明, 在其他领域的生成建模成功的另一种选择是生成对抗网络(GAN)。 GAN通过将判别网络与生成网络相结合来工作。生成网络创建样本, 例如我们的LSTM和自动编码器模型生成的和弦序列。然后将这些样本与真实样本(即来自实际歌曲的和弦序列)一起组合在训练集中。区分模型尝试将样本分类为真实样本或由生成器创建。生成器试图欺骗区分模型将其创建的样本误分类为真实歌曲。对模型进行训练, 直到我们达到稳定的演化平衡为止, 在此情况下, 判别器和生成器都无法获得进一步的优势。
对于那些有兴趣将GAN应用于音乐生成任务的人, 值得参考以下资源:
- MuseGAN是一个旨在使用GAN生成和弦音乐的项目。它与经过预先训练的模型以及174, 154首钢琴谱格式的经过清洗和准备的歌曲捆绑在一起。
- Magenta是一个开源项目, 旨在将生成模型应用于创造性任务, 包括音乐创作。你可以通过Colab笔记本探索Magenta提供的工具集。
资源资源
对于有兴趣了解有关TensorFlow 2.0的更多信息的人员, 请参阅srcmini上的Python中的TensorFlow简介。有关深度学习音乐生成方法的最新全面综述, 请参见Briot, Hadjerest和Pachet(2019)。有关使用深度学习模型生成音乐的其他教程, 请参见1、2、3、4和5, 其中提供了有关生成模型的完整教科书。
附录
连续表示
WAV文件是最常见的连续表示形式, 因为它们包含未压缩的原始音频信号。例如, 这不同于mp3文件, 后者通过丢弃有关音频信号的信息来压缩文件的大小。
下面的代码块显示了如何从WAV文件中提取采样率, 采样率和原始音频信号信号, 该文件包含由莫扎特创作的音乐。采样率是每秒对音频信号进行采样的次数。在我们的例子中, 该值为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的情况。下图显示了三秒钟的信号间隔。
通常, 使用原始音频信号来训练模型比使用离散表示要困难得多。最近对音乐生成的深度学习方法的调查研究了文献中32种不同的音乐生成方法。其中, 只有三个使用连续表示形式:
- 音频样式转移
- 深度自动控制器
- WaveNet
按键移调
可以用于降维或数据扩充目的的一种方法是密钥转置。这需要将每个音符的音调偏移固定的时间间隔。例如, 如果一首歌曲包含一个音高C3之后是音高D3的序列, 并且我们使用将所有元素上移两个音高的移调, 则C3将变为D3, 而D3将变为E3。有关使用键换位的模型, 请参见Lim, Rhyu和Lee(2018)和Sturm等。 (2016)。
如果出于降维目的而使用键移调, 则我们的目标将是移动音符和和弦, 以使数据集中唯一音符和和弦的总数最小化。由于音乐往往是围绕某些音高组编写的, 这些音高被称为”键”, 当一起演奏时听起来不错, 因此我们可以方便地将数据集中的每首歌曲移入相同的键。
这次, 我们将从music21导入三个子模块:
- 转换器, 我们将使用它来加载MIDI文件并将其转换为流对象。
- 音高, 我们将使用它来创建目标音高。
- 间隔, 我们将使用该间隔来计算原始键和目标键之间的距离。
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
我们首先应用.analyze(‘key’)方法进行计分, 以标识流对象的键(C大调)。然后, 我们计算C大调和目标键F大调之间的间隔。注意, 我们使用.tonic访问关键对象的音高。此外, 我们使用pitch.Pitch(‘F’)创建目标音高。然后, 我们将输出分配给keyInterval, 它告诉我们原始键和目标键之间的距离。
最后, 我们使用.transpose()方法转置键并传递间隔keyInterval。打印新密钥, 确认它是F大调。
倍频移调
如果我们想比键移调更进一步, 我们还可以将八度移调到更高或更低。例如, 我们可以在较低或较高的八度音程中将C主音转换为C主音, 而不是将C主音转换为F主音。如果我们为每首歌曲指定相同的八度音阶, 我们将迫使所有音符和和弦聚集在一起, 从而减少问题的范围并简化模型训练过程。在上面的代码块中, 我们可以通过指定音高的八度来实现。例如, 我们可以使用pitch.Pitch(‘F3’)而不是指定pitch.Pitch(‘F’)来计算间隔。
键和八度不变
降维的自然替代方法是数据扩充。也就是说, 我们不减少可能的输入和输出的集合, 而是通过对现有数据进行更改来扩展数据集。我们也可以使用键和八度音调换位。但是, 与其将所有歌曲转换为一个共同的音调并将它们的音符聚集在相邻的八度中, 我们不如将所有歌曲转换为所有键和所有八度音。这将使我们能够在开发的模型中实现键和八度不变。参见Briot, Hadjerest和Pachet(2019)讨论了将换位用于两种不同目的的情况。
数据采集
我们将收集MIDI文件, 以从Mutopia项目中训练我们的模型。在进行任何抓取之前, 我们将首先检查位于https://www.mutopiaproject.org/robots.txt的漫游器文件, 以确保我们尊重所有有关如何与网站进行自动交互的请求处理。在编写本教程时, robots文件在下面的代码块中包含了文本。用户代理:*表示遵循的规则适用于与网站互动的所有用户。禁止:后面没有任何内容, 表示对抓取没有任何限制。
# Allow crawling of all content
User-agent: *
Disallow:
我们将首先从urllib.request导入urlopen和urlretrieve, 我们将使用它来发送吉他节中每个页面的获取请求, 然后下载页面上链接到的每个MIDI文件。我们将导入BeautifulSoup来解析urlopen返回的HTML, 从而使我们能够识别MIDI文件链接。最后, 我们将使用时间模块在两次下载之间暂停10秒钟, 以免对网站资源造成压力。
主循环的每个步骤将检查页面linkCount上的链接数是否超过0。如果超过, 它将使用字符串url0, url1和歌曲编号构造下一页的URL, 以导航到url , songNumber。然后它将打开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)
对于那些有兴趣学习更多有关抓取的知识的人, 请参阅srcmini的Web Scraping with Python课程。
评论前必须登录!
注册