老张说:快过年了,搞个AI作曲,用TensorFlow训练midi文件
theme: devui-blue
一、老张需求:AI作曲营造新年氛围
我有一个搞嵌入式的朋友老张,全名叫张三。是真的,他的身份证上就叫张三。据说,出生时,他父母准备了一堆名字。但是两人各执一派,大打出手。吵闹声引来隔壁李大爷:实在不行叫张三,我叫李四,也活得挺好。于是,互不相让的一对年轻夫妻,给孩子上了户口,起名叫:张三。
老张长大后,一直不和李大爷说话。李大爷告诉小张三:当时,如果,不是我冲进去,急中生智给你定下名字,你可能就没命了。听到这里,小张三才稍微得以释怀,并且给李大爷磕了个头,以示感谢。李大爷说:客气了,我起名字时,我父母也一样,最后还是你爷爷给起的李四,咱两家是世交。
我打断了老张:快说,找我么事?
老张说,其实我一直觉得,我不是普通的凡人。
“嗯,你特别的烦人”。
老张说,是平凡的“凡”。我今年40岁了,哎,你知道吗?我前天刚过完40岁生日,买了一个大蛋……
“说事情!”
老张说,兄弟,帮帮忙吧,我想搞一个发明,需要你人工智能方向的帮助。
我说,啊,你又搞发明?这次什么想法。
老张说,现在快过年了,我想搞一个仿老式留声机的盒子,安装到餐厅里面。
机器采用自助模式,只要顾客付钱,盒子就会自动播放一段,由AI生成的原创新年音乐,给他们送去祝福,扫码还可以下载保存。
我问老张,你有销售渠道吗?
老张说,放心吧,餐厅我都谈好了。他们提供场地和网络,我们只承担电费就行。
我沉思了一会,问老张:老张啊,你认识一个“耿”姓做手工的人吗?
老张说,相信我,我绝对不认识他,他的那些发明,不是没用,是真没用。
我点了点头:那就好,我支持你!
二、midi格式:便携式音乐描述文件
想让AI
学会作曲,首先要找到一批音乐样本,让它学。
AI
作曲,遵循“种瓜得瓜,种豆得豆”的原则:你给它训练什么风格的样本,它最终就会生成什么风格的音乐。
因此,我们需要找一些轻松活泼的音乐,这适合新年播放。
音乐文件的格式,我们选择MIDI
格式。MIDI
的全称是:Musical Instrument Digital Interface
,翻译成中文就是:乐器数字接口。
这是一种什么格式?为什么会有这种格式呢?
话说,随着计算机的普及,电子乐器也出现了。电子乐器的出现,极大地节省了成本,带来了便利。
基本上有一个电子乐器,世间的乐器就都有了。
这个按钮是架子鼓,那个按钮是萨克斯。而在此之前,你想要发出这类声音,真的得敲架子鼓或者吹萨克斯管。
而且还有更为放肆的事情。你想用架子鼓一秒敲五下,得有专业的技能。但是用电子乐器,一秒敲五十下也毫不费力,因为程序就给搞定了。
这些新生事物的出现,常常让老艺术家们口吐鲜血。
电子乐器既然可以演奏音乐,那么就有乐谱。这乐谱还得有标准,因为它得在所有电子乐器中都起作用。这个“计算机能理解的乐谱”,就是MIDI
格式。
下面我们就来解析一下MIDI
文件。看看它的结构是怎么样的。
我找到一个机器猫(哆啦A梦)的主题曲,采用python
做一下解析:
```python import pretty_midi
加载样本文件
pm = pretty_midi.PrettyMIDI("jqm.midi")
循环乐器列表
for i, instrument in enumerate(pm.instruments): instrument_name = pretty_midi.program_to_instrument_name(instrument.program) print(i, instrument_name) # 输出乐器名称 ```
这个音乐,相信大家都很熟悉,就是:哦、哦、哦,哆啦A梦和我一起,让梦想发光……
通过pretty_midi
库加载MIDI
文件,获取它的乐器列表pm.instruments
,打印如下:
Acoustic Grand Piano(原声大钢琴)、Glockenspiel(钢片琴)、String Ensemble(弦乐合奏) 、
Muted Trumpet(闷音小号)、Trombone(长号)、
Electric Bass(电贝斯)、Acoustic Guitar(原声吉他)、
Flute(长笛)、Acoustic Grand Piano(原声大钢琴)、
Harmonica(口琴)、Vibraphone(电颤琴)、Bagpipe(苏格兰风笛)、Marimba(馬林巴琴)……
我们看到,短短一个片头曲,就动用了近20
种乐器。如果不是专门分析它,我们还真的听不出来呐。
那么,每种乐器的音符可以获取到吗?我们来试试:
``` python
承接上个代码片段,假设选定了乐器instrument
for j, note in enumerate(instrument.notes): # 音高转音符名称 note_name = pretty_midi.note_number_to_name(note.pitch) info = ("%s:%.2f->%.2f")%(note_name, note.start, note.end) ```
打印如下:
Acoustic Grand Piano
F#3:1.99->2.04 F#2:1.98->2.06 E2:1.99->2.07 C2:1.98->2.08 F#3:2.48->2.53 F#2:2.48->2.56 F#3:2.98->3.03 F#2:2.98->3.06 ……
通过获取instrument
的notes
,可以读到此乐器的演奏信息。包含:pitch
音高,start
开始时间,end
结束时间,velocity
演奏力度。
| 名称 | pitch | start | end | velocity | | --- | --- | --- | --- | --- | | 示例 | 24 | 1.98 | 2.03 | 82 | | 解释 | 音高(C1、C2) | 开始时间 | 结束时间 | 力度 | | 范围 | 128个音高 | 单位为秒 | 单位为秒 | 最高100 |
上面的例子中,F#3:1.99->2.04
表示:音符F#3
,演奏时机是从1.99
秒到2.04
秒。
如果把这些数据全都展开,其实挺壮观的,应该是如下这样:
其实,MIDI
文件对于一首乐曲来说,就像是一个程序的源代码,也像是一副药的配方。MIDI
文件里,描述了乐器的构成,以及该乐器的演奏数据。
这类文件要比WAV
、MP3
这些波形文件小得多。一段30
分钟钢琴曲的MIDI
文件,大小一般不超过100KB
。
因此,让人工智能去学习MIDI
文件,并且实现自动作曲,这是一个很好的选择。
三、实战:TensorFlow实现AI作曲
我在datasets
目录下,放了一批节奏欢快的MIDI
文件。
这批文件,除了节奏欢快适合在新年播放,还有一个特点:全部是钢琴曲。也就是说,如果打印他的乐器的话,只有一个,那就是:Acoustic Grand Piano
(原声大钢琴)。
这么做降低了样本的复杂性,仅需要对一种乐器进行训练和预测。同时,当它有朝一日练成了AI
作曲神功,你也别妄想它会锣鼓齐鸣,它仍然只会弹钢琴。
多乐器的复杂训练当然可行。但是目前在业内,还没有足够的数据集来支撑这件事情。
开搞之前,我们必须得先通盘考虑一下。不然,我们都不知道该把数据搞成么个形式。
AI
作曲,听起来很高端。其实跟文本生成、诗歌生成,没有什么区别。我之前讲过很多相关的例子《NLP实战:基于LSTM自动生成原创宋词》《NLP实战:基于GRU的自动对春联》《NLP实战:基于RNN的自动藏头诗》。也讲过很多关于NLP的知识点《NLP知识点:Tokenizer分词器》《NLP知识点:文本数据的预处理》等。如果感兴趣,大家可以先预习一下。不看也不要紧,后面我也会简单描述,但深度肯定不如上面的专项介绍。
利用RNN
,生成莎士比亚文集,是NLP
领域的HelloWorld
入门程序。那么,AI
作曲,只不过是引入了音乐的概念。另外,在出入参数上,维度也丰富了一些。但是,从本质上讲,它还是那一套思路。
所有AI
自动生成的模式,基本上都是给定一批输入值+
输出值。然后,让机器去学习,自己找规律。最后,学成之后,再给输入,让它自己预测出下一个值。
举个例子,莎士比亚文集的生成,样本如下:
First Citizen: Before we proceed any further, hear me speak.
All: Speak, speak.
它是如何让AI
训练和学习呢?其实,就是从目前的数据不断观察,观察出现下一个字符的概率。
| 当前 | 下一个 | 经验值 | | --- | --- | --- | | F | i | ☆ | | Fi | r | ☆ | | Fir | s | ☆ | | Firs | t | ☆☆ | | …… | …… | …… |
F
后面大概率会出现i
。如果现在是Fi
,那么它的后面该出现r
了。这些,AI
作为经验记了下了。
这种记录概率的经验,在少量样本的情况下,是无意义的。
但是,当这个样本变成人类语言库的时候,那么这个概率就是语法规范,就是上帝视角。
举个例子,当我说:冬天了,窗外飘起了__!
你猜,飘起了什么?是的,窗外飘起了雪。
当AI
分析过人类历史上,出现过的所有语言之后。当它进行数据分析的时候,最终它会计算出:在人类的语言库里,冬天出现飘雪花的情况,要远远高于冬天飘落叶的情况。所以,它肯定也会告诉你那个空该填:雪花。
这就是AI
自动作词、作曲、作画的本质。它的技术支撑是带有链式的循环神经网络(RNN
),数据支撑就是大量成型的作品。
3.1 准备:构建数据集
首先,读取这些数据,然后把它们加工成输入input
和输出output
。
展开一个MIDI
文件,我们再来看一下原始数据:
Note(start=1.988633, end=2.035121, pitch=54, velocity=82),
Note(start=1.983468, end=2.060947, pitch=42, velocity=78),
Note(start=2.479335, end=2.525823, pitch=54, velocity=82)……
我们可以把前几组,比如前24
组音符数据作为输入,然后第25
个作为预测值。后面依次递推。把这些数据交给AI
,让它研究去。
训练完成之后,我们随便给24
个音符数据,让它预测第25
个。然后,再拿着2~25
,让它预测第26
个,以此循环往后,连绵不绝。
这样可以吗?
可以(能训练)。但存在问题(结果非所愿)。
在使用循环神经网络的时候,前后之间要带有通用规律的联系。比如:前面有“冬天”做铺垫,后面遇到“飘”时,可以更准确地推测出来是“飘雪”。
我们看上面的数据,假设我们忽略velocity
(力度)这个很专业的参数。仅仅看pitch
音高和start
、end
起始时间。其中,音高是128
个音符。它是普遍有规律的,值是1~128
,不会出圈儿。但是这个起始时间却很随机,这里可以是啊1
秒开始,再往后可能就是100
秒开始。
如果,我们只预测2
个音符,结果200
秒的时间出现的概率高。那么,第二个音符岂不是到等到3
分钟后再演奏。另外,很显然演奏是有先后顺序的,因此要起止时间遵从随机的概率分布,是不靠谱的。
我觉得,一个音符会演奏多久,以及前后音符的时间间距,这两项相对来说是比较稳定的。他们更适合作为训练参数。
因此,我们决定把音符预处理成如下格式:
Note(duration=0.16, step=0.00, pitch=54),
Note(duration=0.56, step=0.31, pitch=53),
Note(duration=0.26, step=0.22, pitch=24),
……
duration
表示演奏时长,这个音符会响多久,它等于end-start
。
step
表示步长,本音符距离上一个出现的时间间隔,它等于start2-start1
。
原始数据格式[start,end]
,同预处理后的数据格式[duration,step]
,两者是可以做到相互转化的。
我们把所有的训练集文件整理一下:
``` python import pretty_midi import tensorflow as tf
midi_inputs = [] # 存放所有的音符 filenames = tf.io.gfile.glob("datasets/*.midi")
循环所有midi文件
for f in filenames:
pm = pretty_midi.PrettyMIDI(f) # 加载一个文件
instruments = pm.instruments # 获取乐器
instrument = instruments[0] # 取第一个乐器,此处是原声大钢琴
notes = instrument.notes # 获取乐器的演奏数据
# 以开始时间start做个排序。因为默认是依照end排序
sorted_notes = sorted(notes, key=lambda note: note.start)
prev_start = sorted_notes[0].start
# 循环各项指标,取出前后关联项
for note in sorted_notes:
step = note.start - prev_start # 此音符与上一个距离
duration = note.end - note.start # 此音符的演奏时长
prev_start = note.start # 此音符开始时间作为最新
# 指标项:[音高(音符),同前者的间隔,自身演奏的间隔]
midi_inputs.append([note.pitch, step, duration])
``
上面的操作,是把所有的
MIDI文件,依照预处理的规则,全部处理成
[pitch, step, duration]格式,然后存放到
midi_inputs`数组中。
这只是第一步操作。后面我们要把这个朴素的格式,拆分成输入和输出的结对。然后,转化为TensorFlow
框架需要的数据集格式。
``` python seq_length = 24 # 输入序列长度 vocab_size = 128 # 分类数量
将序列拆分为输入和输出标签对
def split_labels(sequences): inputs = sequences[:-1] # 去掉最后一项最为输入 # 将音高除以128,便于 inputs_x = inputs/[vocab_size,1.0,1.0] y = sequences[-1] # 截取最后一项作为输出 labels = {"pitch":y[0], "step":y[1],"duration":y[2]} return inputs_x, labels
搞成tensor,便于流操作,比如notes_ds.window
notes_ds = tf.data.Dataset.from_tensor_slices(midi_inputs) cut_seq_length = seq_length+1 # 截取的长度,因为要拆分为输入+输出,因此+1
每次滑动一个数据,每次截取cut_seq_length个长度
windows = notes_ds.window(cut_seq_length, shift=1, stride=1,drop_remainder=True) flatten = lambda x: x.batch(cut_seq_length, drop_remainder=True) sequences = windows.flat_map(flatten)
将25,拆分为24+1。24是输入,1是预测。进行训练
seq_ds = sequences.map(split_labels, num_parallel_calls=tf.data.AUTOTUNE) buffer_size = len(midi_inputs) - seq_length
拆分批次,缓存等优化
train_ds = (seq_ds.shuffle(buffer_size) .batch(64, drop_remainder=True) .cache().prefetch(tf.data.experimental.AUTOTUNE)) ```
我们先分析split_labels
这个方法。它接收一段序列数组。然后将其分为两段,最后1
项作为后段,其余部分作为前段。
我们把seq_length
定义为24
,从总数据midi_inputs
中,利用notes_ds.window
实现每次取25
个数据,取完了向后移动1
格,再继续取数据。直到凑不齐25
个数据(drop_remainder=True
意思是不足25
弃掉)停止。
至此,我们就有了一大批以25
为单位的数据组。其实,他们是:1~25
、2~26
、3~27
……
然后,我们再调用split_labels
,将其全部搞成24+1
的输入输出对。此时数据就变成了:(1~24,25)
、(2~25,26)
……。接着,再调用batch
方法,把他们搞成每64
组为一个批次。这一步是框架的要求。
至此,我们就把准备工作做好了。后面,就该交给神经网络训练去了。
3.2 训练:构建神经网络结构
这一步,我们将构建一个神经网络模型。它将不断地由24
个音符观察下一个出现的音符。它记录,它思考,它尝试推断,它默写并对照答案。一旦见得多了,量变就会引起质变,它将从整个音乐库的角度,给出作曲的最优解。
好了,上代码:
``` python input_shape = (seq_length, 3) # 输入形状 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)
``
上面代码我们定义了输入和输出的格式,然后中间加了个
LSTM`层。
先说输入。因为我们给的格式是[音高,间隔,时长]
共3
个关键指标。而且每24
个音,预测下一个音。所以input_shape = (24, 3)
。
再说输出。我们最终期望AI
可以自动预测音符,当然要包含音符的要素,那也就是outputs = {'pitch','step','duration'}
。其中,step
和duration
是一个数就行,也就是Dense(1)
。但是,pitch
却不同,它需要是128
个音符中的一个。因此,它是Dense(128)
。
最后说中间层。我们期望有人能将输入转为输出,而且最好还有记忆。前后之间要能综合起来,要根据前面的铺垫,后面给出带有相关性的预测。那么,这个长短期记忆网络LSTM
(Long Short-Term Memory)就是最佳的选择了。
最终,model.summary()
打印结构如下所示:
|Layer (type) | Output Shape | Param | Connected to| | --- | --- | --- | --- | | input (InputLayer) | [(None, 24, 3)] | 0 | [] | | lstm (LSTM) | (None, 128) | 67584 | ['input[0][0]'] | | duration (Dense) | (None, 1) | 129 | ['lstm[0][0]'] | | pitch (Dense) | (None, 128) | 16512 | ['lstm[0][0]'] | | step (Dense) | (None, 1) | 129 | ['lstm[0][0]'] | | Total params: 84,354 | | |
后面,配置训练参数,开始训练:
``` python checkpoint_path = 'model/model.ckpt' # 模型存放路径 model.compile( # 配置参数 loss=loss, loss_weights={'pitch': 0.05,'step': 1.0,'duration':1.0}, optimizer=tf.keras.optimizers.Adam(learning_rate=0.01), )
模型保存参数
cp_callback = tf.keras.callbacks.ModelCheckpoint( filepath=checkpoint_path ,save_weights_only=True, save_best_only=True)
启动训练,训练50个周期
model.fit(train_ds, validation_data=train_ds
, epochs=50,callbacks=[cp_callback])
``
训练完成之后,会将模型保存在
'model/model.ckpt'目录下。而且,我们设置了只保存最优的一个模型
save_best_only=True`。
上面有个需要特别说明的地方,那就是在model.compile
中,给损失函数加了一个权重loss_weights
的配置。这是因为,在输出的三个参数中,pitch
音高的128
分类跨度较大,一旦预测有偏差,就会导致损失函数的值很大。而step
和duration
本身数值就很小,都是0.0x
秒,损失函数的值变化较小。这种不匹配,会导致后两个参数的变化被忽略,只关心pitch
的训练。因此需要降低pitch
的权重平衡一下。至于具体的数值,是调试出来的。
出于讲解的需要,上面的代码仅仅是关键代码片段。文末我会把完整的项目地址公布出来,那个是可以运行的。
好了,训练上50
轮,保存完结果模型。下面,就该去做预测了。
3.3 预测和播放:实现AI作曲
现在这个模型,已经可以根据24
个音符去推测出下一个音符了。我们来试一下。
``` python
加载模型
if os.path.exists(checkpoint_path + '.index'): model.load_weights(checkpoint_path)
从音符库中随机拿出24个音符,当然你也可以自己编
sample_notes = random.sample(midi_inputs, 24) num_predictions = 600 # 预测后面600个
循环600次,每次取最新的24个
for i in range(num_predictions):
# 拿出最后24个
n_notes = sample_notes[-seq_length:]
# 主要给音高做一个128分类归一化
notes = []
for input in n_notes:
notes.append([input[0]/vocab_size,input[1],input[2]])
# 将24个音符交给模型预测
predictions = model.predict([notes])
# 取出预测结果
pitch_logits = predictions['pitch']
pitch = tf.random.categorical(pitch_logits, num_samples=1)[0]
step = predictions['step'][0]
duration = predictions['duration'][0]
pitch, step, duration = int(pitch), float(step), float(duration)
# 将预测值添加到音符数组中,进行下一次循环
sample_notes.append([pitch, step, duration])
``
其实,关键代码就一句
predictions = model.predict([notes])。根据
24个音符,预测出来下一个音符的
pitch、
step和
duration`。其他的,都是辅助操作。
我们从素材库里,随机生成了24
个音符。其实,如果你懂声乐,你也可以自己编24
个音符。这样,起码能给音乐定个基调。因为,后面的预测都是根据前面特征来的。当然,也可以不是24
个,根据2
个生成1
个也行。那前提是,训练的时候也得是2+1
的模式。但是,我感觉还是24
个好,感情更深一些。
从24
个生成1
个后,变成了25
个。然后再取这25
个中的最后24
个,继续生成下一个。循环600
次,最后生成了624
个音符。打印一下:
[[48, 0.001302083333371229, 0.010416666666628771],
[65, 0.11979166666674246, 0.08463541666651508]
……
[72, 0.03634712100028992, 0.023365378379821777],
[41, 0.04531348496675491, 0.011086761951446533]]
但是,这是预处理后的特征,并非是可以直接演奏的音符。是否还记得duration = end-start
以及step=start2-start1
。我们需要把它们还原成为MIDI
体系下的属性:
``` python
复原midi数据
prev_start = 0 midi_notes = [] for m in sample_notes: pitch, step, duration = m start = prev_start + step end = start + duration prev_start = start midi_notes.append([pitch, start, end]) ```
这样,就把[pitch, step, duration]
转化成了[pitch, start, end]
。打印midi_notes
如下:
[[48, 0.001302083333371229, 0.01171875],
[65, 0.12109375000011369, 0.20572916666662877],
……
[72, 32.04372873653976, 32.067094114919584],
[41, 32.08904222150652, 32.100128983457964]]
我们从数据可以看到,最后播放到了32
秒。也就说我们AI
生成的这段600
多个音符的乐曲,可以播放32
秒。
听一听效果,那就把它写入MIDI
文件吧。
``` python
写入midi文件
pm = pretty_midi.PrettyMIDI() instrument = pretty_midi.Instrument( program=pretty_midi.instrument_name_to_program("Acoustic Grand Piano")) for n in midi_notes: note = pretty_midi.Note(velocity=100,pitch=n[0],start=n[1],end=n[2]) instrument.notes.append(note) pm.instruments.append(instrument) pm.write("out.midi") ```
MIDI
文件有5
个必需的要素。其中,乐器我们设置为"Acoustic Grand Piano"
原声大钢琴。velocity
没有参与训练,但也需要,我们设为固定值100
。其他的3
个参数,都是AI
生成的,依次代入。最后,把结果生成到out.midi
文件中。
使用Window
自带的Media Player
就可以直接播放这个文件。你听不到,我可以替你听一听。
听完了,我谈下感受吧。
怎么描述呢?我觉得,说好听对不起良心,反正,不难听。
好了,AI
作曲就到此为止了。
源代码已上传到GitHub
地址是:https://github.com/hlwgy/ai_music。
做完了,我还得去找老张谈谈。
四、合作:你果然还是这样的老张
我骑电动车去找老张,我告诉他,AI
作曲哥们搞定了。
老张问我,你阳了没有。
我说,没有。
老张告诉我,他表弟阳了。
我说,你不用担心,毕竟你们离得那么远。
老张说,他阳了后,我们的项目也泡汤了。
我问为什么。
老张说:我谈好的那家饭店,就是表弟开的。他阳了之后,现在不承认了。
我的电脑还停留在开机界面,我强制关机。走了。
我记得,上一次,我告诉老张,三个月不要联系我《老张让我用TensorFlow识别语音命令》。
而这一次,我什么也没有说。就走了。
我是TF男孩,带你从IT视角看世界。
- 60分钟的文心一言发布会:我带你5分钟看完
- 程序员写小说:我甘心当韭菜
- docx格式文档详解:xml解析并用html还原
- 评论二则:年龄大了能否写代码、上大学不喜欢IT专业咋整
- ChatGPT火了,我连夜详解AIGC原理,并实战生成动漫头像
- 咱不吃亏,也不能过度自卫
- 兔年了,利用AI风格化实现剪纸兔、年画兔、烟花兔
- 傻吗?谈男人们饭桌的拼酒现象
- 他扔出一张对话截图:王总说的
- 老张说:快过年了,搞个AI作曲,用TensorFlow训练midi文件
- 为什么大家都看中学历?
- 年底了,裁兄弟当职员那哥们儿,如今咋样了?
- 老张让我用TensorFlow识别语音命令:前进、停止、左转、右转
- 在掘金第一次记录失眠
- 十五分钟简介人工智能,以听懂为目的
- 认知史 12:积累阶段,没资格攀比
- 认知史 16:人过留名,雁过留声
- 认知史 4:普遍共识
- 一文讲通OCR文字识别原理与技术全流程(科普版)
- 认知史 7:选择和努力