使用Tensorflow的RNN(LSTM)生成音乐(基础)

跟着Tensorflow的官方教程,搭建一个简单的LSTM模型,生成midi格式的音乐。
只是为了熟悉tensorflow机器学习的代码一般格式,在音乐生成的模型上有很多不合理的操作,所以结果也不太好。

安装

用的目前最新版Tensorflow,也就是2.80
使用pretty_midi库来读取midi文件,仅针对没有速度、节拍等信息的midi文件
其余库如numpy, pandas处理数据,matplotlib画图
没有安装官方教程的pyfluidsynth合成器来生成音频,反正一般人电脑都能播放midi

数据集使用GiantMIDI-Piano,百度搜出来的

基本原理

从midi文件中读入单个乐器的音符列表,记录音符的pitch(音高),step(音符起始时间距上一个音符起始时间的距离),duration(音符的长度),时间单位都是秒,全都以float类型记录
PS:显然不太合理,没有记录音乐中重要的节拍信息,还把音高这种离散数据视为连续的,但这不是重点

从数据集中取出连续的sequence_length个音符输入进LSTM模块(原理略),得到的输出分别用三个全连接层处理,得到预测的下一个音符的pitch,step,duration

读取MIDI文件

pretty_midi库简单介绍

pretty_midi比较简单好用
读取midi文件

pm = pretty_midi.PrettyMIDI(midi_file)

然后在PrettyMIDI对象pm中,pm.instruments即是乐器列表

instrument = pm.instruments[0]

instrument即为第一个乐器,类型为pretty_midi.Instrumentinstrument.notes即为该乐器的音符列表
音符类型pretty_midi.Note有4个属性

note.start 		#开始时间
note.end 		#结束时间
note.pitch		#音高
note.velocity 	#音符力度

创建midi文件pm.write(midi_file)即可

处理midi数据

首先将音符按起始时间排序,然后从里面处理出需要的5个信息,做成dict,生成pandas的DataFrame

def midi_to_notes(midi_file: str) -> pd.DataFrame:
    pm = pretty_midi.PrettyMIDI(midi_file)
    instrument = pm.instruments[0]
    notes = {'pitch': [], 'start': [], 'end': [], 'step': [], 'duration': []}

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

pandas的Dataframe可以理解为一种表格数据类型,就像这样
DataFrame(盗一下TF官方教程的表格)

创建midi文件

将DataFrame转换为midi文件输出,其中instrument_name必须是midi文件预设的乐器名(随便网上一搜就有)

def notes_to_midi(notes: pd.DataFrame, out_file: str, instrument_name: str, velocity: int = 100) -> 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

加载数据

使用glob库,利用通配符读入所有mid文件
对每一个文件读入他的音符列表,合并后生成总的DataFrame
然后将其转化为tensorflow中处理数据的类型tf.data.Dataset
Dataset可以理解为一个存放输入数据的数组,带有很多机器学习处理数据的功能,比如拆分test/train,分batch,多线程处理等等。

def load() -> tf.data.Dataset:
    filenames = glob.glob("GiantMIDI-Piano\\midis\\*.mid")
    print('Number of files:', len(filenames))

    num_files = 100
    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)

    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)
    print(notes_ds.element_spec)

    return notes_ds

处理数据

由于我们的训练数据是输入若干个音符的序列,预测下一个音符,我们需要把加载的音符列表数据集变为连续音符序列的数据集
使用Dataset.window函数可以得到一个装满Dataset的Dataset,每个子Dataset为一个序列,像这样↓

Dataset[1,2,3,4,5,6]
window(3)后变成
Dataset[Dataset[1,2,3],Dataset[2,3,4],Dataset[3,4,5],Dataset[4,5,6]]

但是Dataset的Dataset不方便输入,需要将子Dataset全部展开
Dataset.flat_map函数可以将子Dataset全部执行一个函数后,展开放在新的Dataset里,在这里使用batch函数
batch函数本来的功能是将原来的数据集分组

Dataset[1,2,3,4,5,6]
batch(3)后变成
Dataset[[1,2,3],[4,5,6]]

在这里我们对每一个window后的子Dataset执行后,相当于只是增加一个维度Dataset[1,2,3]--->Dataset[[1,2,3]]

于是这一套flat_map下来的过程就是

Dataset[Dataset[1,2,3],Dataset[2,3,4],Dataset[3,4,5],Dataset[4,5,6]]
Dataset[Dataset[[1,2,3]],Dataset[[2,3,4]],Dataset[[3,4,5]],Dataset[[4,5,6]]]
Dataset[[1,2,3],[2,3,4],[3,4,5],[4,5,6]]

这样就方便多了

在这里,我们需要提前将sequence_length+1,多一个作为标签(待预测的下一个音符)
然后进行window, flag_map等操作,最后再用一个map将每一个序列的最后一个音符划出去作为标签,最终的数据集就变成Dataset[(...,1),(...,1)......]
于此同时将音高pitch归一化 (PS:对音乐非常不合理的操作)

def create_sequences(
    dataset: tf.data.Dataset,
    seq_length: int,
    vocab_size=hp.vocab_size,
) -> 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
    def flatten(x): return 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(hp.key_order)}

        return scale_pitch(inputs), labels

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

def create_train_data(notes_ds: tf.data.Dataset):
    buffersize = len(notes_ds)-hp.seq_length
    seq_ds = create_sequences(notes_ds, hp.seq_length, hp.vocab_size)
    return seq_ds.shuffle(buffersize).batch(hp.batch_size, drop_remainder=True)

个人认为这一堆类型转换特别的复杂而且没必要,不如pyTorch简洁。
但也有一点点道理,pyTorch没有这些map操作,而tensorflow的map操作可以多核运行更快
(进一步学习发现pyTorch有其他多线程运行的方法,但我还没学会。。。)

构建模型

继承tf.keras.Model构建自定义模型
一个LSTM处理输入的音符,再分别用三个全连接层算出pitch,step,duration
在这里LSTM的输入维度为 ( N , L , H i n ) (N,L,H_{in}) (N,L,Hin),分别为batch,序列长度,输入维度
pitch特征输出为128维,表示每个音高出现的权重
step和duration都是一维的标量

class MyModel(tf.keras.Model):
    def __init__(self):
        super().__init__()
        self.lstm = tf.keras.layers.LSTM(units=128)
        self.pitch_dense = tf.keras.layers.Dense(units=128, name='pitch')
        self.step_dense = tf.keras.layers.Dense(units=1, name='step')
        self.duration_dense = tf.keras.layers.Dense(units=1, name='duration')

    def call(self, x):
        x = self.lstm(x)
        output = {'pitch': self.pitch_dense(x),
                  'step': self.step_dense(x),
                  'duration': self.duration_dense(x)}
        return output
    

用Tensorflow自带的一堆函数可以使训练部分的代码更简单
需要用model.compile指定一些信息:损失函数(多个损失值还需要指定权重),优化策略

对于损失函数,pitch使用交叉熵(常用于分类器),而另外两个标量用均方差并带上使他变为正数的压力(毕竟时间都是正数)
tf.keras.losses.SparseCategoricalCrossentropy函数中,输入的标签比预测值少一维
比如标签[1,2,3]与预测值[[0.05, 0.95, 0], [0.1, 0.8, 0.1],[0.3,0.7,0.0]]计算,自动转为下面两组的交叉熵

[[1.00, 0.00, 0], [0.0, 1.0, 0.0],[0.0,0.0,1.0]
[[0.05, 0.95, 0], [0.1, 0.8, 0.1],[0.3,0.7,0.0]

在compile过后要进行build操作,指定输入的维度 ( N , L , H i n ) (N,L,H_{in}) (N,L,Hin)。(毕竟模型里面没有指定)
build后,model.summary()可以输出模型的概要

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)

model = MyModel()
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=0.005)
model.compile(loss=loss, loss_weights={
    'pitch': 1.0,
    'step': 100.0,
    'duration': 100.0,
}, optimizer=optimizer)
model.build(input_shape=(None, hp.seq_length, 3))
model.summary()
'''
Model: "my_model"
_________________________________________________________________
 Layer (type)                Output Shape              Param #
=================================================================
 lstm (LSTM)                 multiple                  67584

 pitch (Dense)               multiple                  16512

 step (Dense)                multiple                  129

 duration (Dense)            multiple                  129

=================================================================
Total params: 84,354
Trainable params: 84,354
Non-trainable params: 0
'''

关于loss权重的设置
可以先预先运行一下

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

得到以下结果,然后根据结果把几个参数的loss规模调得相近

81/81 [==============================] - 4s 9ms/step - loss: 135.8720 - duration_loss: 1.0230 - pitch_loss: 4.8395 - step_loss: 0.2873
{'loss': 135.8720245361328, 'duration_loss': 1.023013949394226, 'pitch_loss': 4.839544296264648, 'step_loss': 0.28731074929237366}

训练模型

运用model.fit()可以实现一键完成训练
需要指定训练数据集,epoch数,还可以指定回调函数callback
回调函数是每个epoch训练完成后会执行的函数,可以进行模型保存,检查是否还要继续训练等操作
在这里,ModelCheckpoint用于模型保存,这里只保存模型里面的参数,每个epoch存一次
EarlyStopping用于决定是否停止训练,比如loss连续几次下降并不多的时候,最多等patience次epoch就不训练了
最后的plt画出loss下降的曲线图

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),
]

history = model.fit(
    train_ds,
    epochs=hp.epochs,
    callbacks=callbacks,
)

plt.plot(history.epoch, history.history['loss'], label='total loss')
plt.show()

生成音乐

预测下一个音符

首先,读入的音符序列需要增加一个维度来代表batch,因为模型的输入是带有batch维度的
tf.expand_dims(notes,0)即是在第0维前添加一维([0,1,2]-->[[0,1,2]]
然后将输入数据扔进模型里得到predictions
根据prediction中音高pitch的128位权重输出,按权重随机产生音符(tf.random.categorical)
由于输出中pitch,duration,step都是带有一维batch的,所以使用tf.squeeze把batch维度去掉([[2]]-->[2]
最后要将step与duration与0取max,防止输出负数时间

def predict_next_note(
        notes: np.ndarray,
        keras_model: MyModel,
        temperature: float = 1.0):
    """Generates a note IDs using a trained sequence model."""

    # 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 = tf.maximum(0, step)
    duration = tf.maximum(0, duration)

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

生成序列

首先需要一个起始的输入序列作为灵感
从sample_midi_file中读入raw_notes,然后把这个pd.DataFrame转换为numpy数组
取出最后的seq_length个音符,并进行归一化,就可以往模型里塞了。
具体操作就是每预测一个音符,就先删除输入序列的第一个音符,并将生成的音符放进输入序列的末尾

def generate(sample_midi_file: str, output_midi_file: str, model, num_predictions):
    raw_notes = midi_to_notes(sample_midi_file)
    sample_notes = np.stack([raw_notes[key] for key in hp.key_order], axis=1)

    input_notes = (
        sample_notes[:hp.seq_length] / np.array([hp.vocab_size, 1, 1]))

    generated_notes = []
    prev_start = 0
    for _ in range(num_predictions):
        pitch, step, duration = predict_next_note(
            input_notes, model, hp.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=(*hp.key_order, 'start', 'end'))
    print(generated_notes.head(10))

    out_pm = notes_to_midi(
        generated_notes, out_file=output_midi_file, instrument_name="Acoustic Grand Piano")

解释用到了numpy的delete和append函数
delete删除axis维度的第k个位置

a=[[1,2,3],
[4,5,6],
[7,8,9]]
np.delete(a,1,axis=0)
'''
array([[1, 2, 3],
       [7, 8, 9]])
'''
np.delete(a,1,axis=1)
'''
array([[1, 3],
       [4, 6],
       [7, 9]])
'''

append函数将一个数组和另一个数组内的元素叠在一起,叠在axis维度上,引用一个讲得好的文章

总结

完整代码:https://gitee.com/CaptainChen/simple_rnn_-aicomposer/tree/master/tf
(不用github是因为慢+懒得挂梯子)
所以一个tensorflow的大体结构就是

  1. 读入数据,经常是用pandas的DataFrame来处理表格
  2. 对数据进行一堆map操作使其变为能够训练的数据,类型变为tf.data.Dataset
  3. 继承tf.keras.Model构建模型
  4. 指定损失函数,回调函数,用fit一键训练,保存模型
  5. 读入模型,进行测试

个人感受tensorflow的数据处理偏冗杂,太多类型转换,还是pyTorch简便
对于一个C++选手兼前Oier,再用熟那一堆花里胡哨的"一键完成"的函数前,可能手敲一份更快

  • 3
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 9
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值