用Python演奏音樂


背景

筆者什么樂器也不會,樂理知識也只有中小學音樂課學的一點點。不過借助Python,調用編曲家常用的MIDI程序庫,也能彈奏出一些簡單的音樂,以下是筆者的一些心得。

准備

安裝mingus

首先是安裝Python庫,我選擇的是mingus,它的優點是教程寫的很詳細,而且和實際的樂理,像調性、節拍這些結合的較好,而不是像同類庫通過發送“按下按鍵”、“釋放按鍵”這些指令來播放聲音,另一方面它可以在運行的時候播放制作出的音樂,不用先導出MIDI文件再渲染音頻。這個庫安裝很簡單,直接

pip install mingus

即可。

下載並配置fluidsynth

mingus這個庫只是提供了調用的接口,接下來需要安裝實際處理MIDI格式的程序fluidsynth。首先在github下載對應的版本,下載后解壓,在文件夾中找到libfluidsynth-2.dll,把這個文件夾添加到環境變量path。然后……比較坑的一點來了,我們下載的這個庫是libfluidsynth-2,但是mingus只認libfluidsynth和libfluidsynth-1,所以需要把mingus的代碼改一下,找到mingus所在文件夾(通常是Python安裝文件夾/Lib/site-packages/mingus),打開/midi/pyfluidsynth.py,將里面第35行起

lib = (
    find_library("fluidsynth")
    or find_library("libfluidsynth")
    or find_library("libfluidsynth-1")
)

改成

lib = (
    find_library("fluidsynth")
    or find_library("libfluidsynth")
    or find_library("libfluidsynth-1")
    or find_library("libfluidsynth-2")
)

之后運行python,嘗試

from mingus.midi import fluidsynth

沒有報錯則此步完成。

下載soundfont文件

soundfont文件一般用來存儲樂器的聲音。網上很多資源因為年代久遠都涼了,找了很久才找到一個。下載以后解壓,然后把文件夾的名字和文件夾里所有文件的名字里的空格和除擴展名之外的點全部去掉,之后找到后綴名為sf2的文件,這個就是我們要找的,假設它的路徑為"D:\Apps\fluidsynth-x64\GeneralUserSoftSynth\GeneralUserSoftSynth.sf2",則我們在程序中調用就用

fluidsynth.init(r'D:\Apps\fluidsynth-x64\GeneralUserSoftSynth\GeneralUserSoftSynth.sf2')

即可。注意那個r,有它字符串里的反斜杠就不用轉義了。這句話沒有報錯則此步完成。

分析

樂譜格式

以郭靜的《每一天都不同》為例,簡譜是這樣的(來自簡譜網):

《每一天都不同》簡譜

我們可以看到樂譜基本上可以用五部分描述:

  • 一是1234567那些數字,注意我用鍵盤數字上方的特殊符號表示這個音升了半音;
  • 二是這些數字所在的八度,即這些數字頭頂和腳下有沒有點,通常沒有點的是第四個八度,頭頂有點的是第五個八度,腳下有點的是第三個八度;
  • 三是某個音的時值,即它占幾拍,注意為了聲音的連貫,延音線相連的兩個音符如果音高相等,我就把它們的時值加起來了,同時為了計算方便,我定義⅛拍為0,¼拍為1,½拍為2,一拍為4,以此類推,如果大於9則用a、b、c這些代替;
  • 四是各樂句的先后順序,我們可以把每條樂句的音符描述出來,然后用一個序列記錄依次出現的樂句的序號;
  • 五是樂句的首調,即左上角的1=D,因為有些歌中間會突然升降調,所以我們必須建一個序列存儲依次出現的樂句的調性,出於簡單考慮,直接記錄首調和C調差多少個半音就行了,比如D調和C調相差2(中間隔個C#),就記錄2即可。

因此我們的程序只要有這五個數據就可以彈奏出整首樂曲了。比方說這首歌前奏的前四個小節,第一部分就可以表示為12317716,第二部分就可以表示為44443454,第三部分就可以表示為3111244g。

我將整個樂譜用json文件改寫如下:

{
    "音符": [
        "12317716",
        "031200123316012155152523000123152067137017606711233200",
        "031200123316012155152523000123152023277105671234352110554",
        "3054325103453160565224330665355332552201236",
        "5433431212345617156^143211177",
        "505112523210231234327125077125231067167176101122343455554",
        "54334312554",
        "50511252321"
    ],
    "音高": [
        "44443454",
        "444444444443444433434344444444444433443443343344444444",
        "444444444443444433434344444444444444433443334444434444444",
        "4434444444444444444444444444444444444444444",
        "44444444444444545444544444433",
        "444444444444444444443444433443444433433433444444444444444",
        "44444444444",
        "44444444444"
    ],
    "節拍": [
        "3111244g",
        "421542112114211112262118442112226211224211421121122844",
        "421542111214211112262118442112226211112422311211312184211",
        "4111142a211222621111211a1111211222112221122",
        "8314222i22222211a222a22224444",
        "4211833211a211211222112211112221121121121142112111111c211",
        "8314222e211",
        "4211833211e"
    ],
    "組成": [0, 0, 1, 2, 3, 4, 2, 3, 5, 3, 6, 3, 7],
    "調性": "2222222222222"
}

樂譜解析

這樣我們就可以在程序中解析它了。解析的代碼如下:

def tran(x):
    if x >= 'a':
        return ord(x) - 87
    elif x == '0':
        return 0.5
    else:
        return float(x)

f = open('每一天都不同.json', 'rb')
data = json.loads(f.read(), encoding='utf8')
f.close()

n = data['音符']
h = data['音高']
r = data['節拍']
l = data['組成']
k = data['調性']
t = Track()
b = Bar('C', (4, 4))
b.place_rest(1)
t.add_bar(b)
name = 'CDEFGAB'
symbol = '!@#$%^&'
for i in range(len(l)):
    rn = list(map(tran, r[l[i]]))
    b = Bar('C', (4 * sum(rn) / 8, 4))
    for j in range(len(n[l[i]])):
        if n[l[i]][j] == '0':
            b.place_rest(8 / rn[j])
        else:
            x = symbol.find(n[l[i]][j])
            if x == -1:
                x = int(n[l[i]][j]) - 1
                y = name[x]
            else:
                y = name[x] + '#'
                print(y)
            note = Note(y, int(h[l[i]][j]))
            note.transpose(k[i])
            b.place_notes(note, 8 / rn[j])
    t.add_bar(b)
  • track在這個庫中表示音軌,bar表示的應該是小節,但是我偷懶了,把bar直接存儲樂句了。在track的開頭,我添加了一個2拍的休止符,因為這個庫不知道是bug還是什么,如果track開頭沒有休止符,則樂曲的第一個音會被吞掉。

  • bar的構造函數有兩個參數,前者隨便填無影響,可能只是元信息,后者比較重要,它描述了這個小節的時長,如果小節里放的音符總時長超過了這個小節的時長最后一個音符會被扔掉,所以一定要計算好。這個時長用分數表示,但它的計算方式很奇怪,(4, 4)表示2拍,以此類推(8, 4)表示4拍,和正常情況完全不一樣。之后對於每個樂句,我首先把時長轉化成數,然后計算樂句的時長,因為我的樂譜8為2拍,所以要除8再乘4。

  • 接着就該填什么音就填什么音。但要注意兩點,一是庫里1234567分別用CDEFGAB代替;二是庫中對時值的描述和我們的描述是倒數關系,它是8為¼拍,4為½拍,2為1拍,以此類推,所以在傳入place_notes和place_rest我們的時值要用8除。

  • note.transpose用來轉調,它能夠把音符提升一定的半音數。

彈奏音樂

然后我們就可以聽聽彈奏出來的音樂了,播放的代碼如下:

fluidsynth.init(r'D:\Apps\fluidsynth-x64\GeneralUserSoftSynth\GeneralUserSoftSynth.sf2')
fluidsynth.set_instrument(1, 11)
fluidsynth.play_Track(t, channel=1, bpm=150)

set_instrument方法可以用來改變某個頻道使用的樂器,比如上面的代碼把第一個頻道的樂器改成編號為11的樂器,如果不執行這段代碼則默認使用第一個樂器即鋼琴。各編號對應的樂器可以在這里查看。play_Track方法第一個參數是要播放的track,第二個是在哪個頻道播放,第三個是播放的速度,默認是120,個人感覺調到150速度比較合適。

添加伴奏

音樂是聽到了,但是有點單調,我們希望加入鼓點、合奏之類的。不過我懷疑這個庫的編寫者沒有對這個庫進行完善的測試,所以原來用於播放多個track的方法play_Tracks有bug。筆者使用了多線程的方式來同時播放,但是庫中還有一個無法調節播放使用的channel的bug,我已向項目提了pull request,截至本文撰寫的時候,項目維護者還沒有回應,所以在這里給出修改方法:打開庫所在文件夾/containers/note.py,將第47行起

    channel = 1	
    velocity = 64

這兩行刪掉。

然后,我們給歌曲添上鼓點。為了方便,我就設置半拍敲一下,每兩拍為一個周期,按照強,弱,次強,弱來,當樂器被設置成鼓的時候,聲音越高,鼓點越弱,聲音越低,鼓點越強,所以我們可以寫出這樣的代碼:

t2 = Track()
b = Bar('C', (4, 4))
b.place_rest(1)
t2.add_bar(b)
for i in range(int(sum(map(sum, map(lambda x: map(tran, r[x]), l)))) // 8):
    b = Bar('C', (4, 4))
    b.place_notes('C-3', 4)
    b.place_notes('C-7', 4)
    b.place_notes('C-5', 4)
    b.place_notes('C-7', 4)
    t2.add_bar(b)

i的范圍是通過對每個樂句的時值求和得到的。接下來是播放,為了讓聲音更好聽,我除了歌曲track、鼓點track再加上一個用另一種樂器演奏的歌曲track,播放的代碼如下:

fluidsynth.init(r'D:\Apps\fluidsynth-x64\GeneralUserSoftSynth\GeneralUserSoftSynth.sf2')
fluidsynth.set_instrument(0, 11)
fluidsynth.set_instrument(1, 115)
fluidsynth.set_instrument(2, 100)
fluidsynth.main_volume(1, 50)
fluidsynth.main_volume(2, 40)
thread1 = threading.Thread(target=lambda : fluidsynth.play_Track(t2, channel=1, bpm=150))
thread2 = threading.Thread(target=lambda : fluidsynth.play_Track(t, channel=2, bpm=150))
thread1.start()
thread2.start()
fluidsynth.play_Track(t, channel=0, bpm=150)

保存音樂

得到音樂以后,我們希望將它保存下來,保存代碼如下:

m = MidiFile()
mt = MidiTrack(150)
mt2 = MidiTrack(150)
mt3 = MidiTrack(150)
m.tracks = [mt, mt2, mt3]
mt.set_instrument(1, 11)
mt.play_Track(t)
for _, _, i in t2.get_notes():
    if i is not None:
        i[0].set_channel(2)
mt2.set_instrument(2, 115)
mt2.play_Track(t2)
for _, _, i in t.get_notes():
    if i is not None:
        i[0].set_channel(3)
mt3.set_instrument(3, 100)
mt3.track_data += mt3.controller_event(3, 7, 30)
mt3.play_Track(t)
m.write_file('D:/test.midi', False)

首先建立MidiFile對象表示一個Midi文件,然后創建3個速度為150的Midi音軌,之后分別是設置樂器和播放頻道,坑的是這個庫里MidiTrack.play_Track方法無法傳入播放頻道,所以需要手動設置track里所有的note的頻道, mt3.controller_event(3, 7, 30)這個方法是為了設置第三個midi音軌的音量,3表示頻道,7表示修改音量這個事件的編號,30是音量,注意是controller_event不是midi_event,我被這個坑了好久,直到看了CMU的MIDI教程,才幡然醒悟,這個庫的基礎設施還是太差了,如果不是它的對象結構和實時播放,真的一無是處。

得到midi文件,我們就可以將其渲染成wav文件了,直接用上之前下載的fluidsynth程序,執行

fluidsynth -F output.wav D:/Apps/fluidsynth-x64/GeneralUserSoftSynth/GeneralUserSoftSynth.sf2 D:/test.midi

得到的output.wav就是我們要的音頻文件。我用ffmpeg轉碼后得到的mp3音頻如下:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM