老張說:快過年了,搞個AI作曲,用TensorFlow訓練midi檔案

語言: CN / TW / HK

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 ……

通過獲取instrumentnotes,可以讀到此樂器的演奏資訊。包含: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檔案裡,描述了樂器的構成,以及該樂器的演奏資料。

這類檔案要比WAVMP3這些波形檔案小得多。一段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音高和startend起始時間。其中,音高是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~252~263~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'}。其中,stepduration是一個數就行,也就是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分類跨度較大,一旦預測有偏差,就會導致損失函式的值很大。而stepduration本身數值就很小,都是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個音符,預測出來下一個音符的pitchstepduration`。其他的,都是輔助操作。

我們從素材庫裡,隨機生成了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地址是:http://github.com/hlwgy/ai_music

做完了,我還得去找老張談談。

四、合作:你果然還是這樣的老張

我騎電動車去找老張,我告訴他,AI作曲哥們搞定了。

老張問我,你陽了沒有。

我說,沒有。

老張告訴我,他表弟陽了。

我說,你不用擔心,畢竟你們離得那麼遠。

老張說,他陽了後,我們的專案也泡湯了。

我問為什麼。

老張說:我談好的那家飯店,就是表弟開的。他陽了之後,現在不承認了。

我的電腦還停留在開機介面,我強制關機。走了。

我記得,上一次,我告訴老張,三個月不要聯絡我《老張讓我用TensorFlow識別語音命令》。

而這一次,我什麼也沒有說。就走了。

我是TF男孩,帶你從IT視角看世界。