这学期的公选课是《手风琴室内乐艺术导赏》。老师真的教得非常的用心,经常自掏腰包打印乐理练习给我们做。所以期末论文我也就写的稍稍用心了点(指四倍于要求的字数)。其中论文的第二部分是“我的专业和音乐的关系”,除了泛泛而谈一些联系外,还试着做了这个简易的编曲程序,一起来看看吧!

# intro

  • 可自定义音色:通过数学定义来更改音色
  • 可自定义一拍的时值:默认一个四分音符为0.5秒
  • 使用唱名来编曲:支持钢琴全部88键,编曲后输出音频文件

待优化的部分:(因为是简易,所以这部分暂时摆了)

  • 目前没有设定 I/O 接口
  • 编曲过程中对时值的设定不够方便
  • 没有支持和弦

# 使用的相关库

  • 音频信号储存在array数组中,所以需要用到 numpy
  • python生成或者打开音频文件使用的库是 scipy.io.wavfile

可选:

  • 播放音频文件使用的库是 audio。(但其实可以导出wav文件之后直接点开播放,不用在程序中进行播放。
  • 显示音频图像的库是 matplotlib.pyplot

# 原理介绍

首先定义一个声音信号函数f(t),t是时间轴,这个函数分为两个部分:

  • 第一部分是定义音色,可以是任意函数。这里我选用的函数是 exp(-10t)。这个定义得到的音色是类似马林巴(一种击打乐器)的音色,刚好公选课上老师也介绍过这个乐器,个人感觉挺好听的。
  • 第二部分是定义音高,用三角函数来定义。我用的是 sin(2πft)。f即是声音的频率,频率决定音高。
# 定义声音信号函数 (import numpy as np)
def func(freq, t):
    return np.exp(-10 * t) * np.sin(2 * np.pi * freq * t)

有了函数定义之后,只需要在时间轴上将一首曲子的音符序列(用唱名序列来映射到声音频率序列)逐一输入到函数中,每个音符可以赋予不同的时值,最终得到一个一维数组音频信号,再写入到.wav文件中便得到了一个可以播放的音频文件。

wav是常见的音频格式之一,支持int16、int32、float32、float64等编码。不过Windows的默认播放器只能播放整形编码,所以在最后输出音频文件的时候还需要先把音频振幅压缩后转为int型。

# 88个音符定义

基本原理:钢琴上的中央C的音符频率为 261.63Hz。此外,每个半音之间的频率倍率差是 2^(1/12)

也就是说,中央C右边的第一个黑键的频率是 261.63Hz * 2^(1/12) ≈ 277Hz。然后再往右一个半音,也就是“re”,他的频率 277Hz * 2^(1/12) ≈ 294Hz。同理,中央C往左的第一个白键“xi”的频率是 261.63Hz / 2^(1/12) ≈ 247Hz。只要不断地进行迭代运算,就可以得到钢琴上所有黑白键对应的频率(此处可以写一个简易脚本来自动帮我们算出全部数据)。

之后再把这些频率值放进python字典中作为value,key则是每个音符的唱名(do、#re、re、#re、mi、fa、#fa、sol、#sol、la、#la、xi)。每个12个音可以放进同一个字典,字典名称的下标可以是这个字组在钢琴上的位置。例如小字一组的下标是4,小字组的下标是3。

程序定义如下:

# 音符定义 f(frequency)是频率值 / n(note)是唱名到声音频率的映射
f0 = [29,   31,   33]
n1 = {"la":f0[0], "#la":f0[1], "xi":f0[2]}
f1 = [35,   37,   39,   41,   44,   46,   49,   52,   55,   58,   62,   65]
n1 = {"do":f1[0], "#do":f1[1], "re":f1[2], "#re":f1[3], "mi":f1[4], "fa":f1[5],
         "#fa":f1[6], "sol":f1[7], "#sol":f1[8], "la":f1[9], "#la":f1[10], "xi":f1[11]}
f2 = [65,   69,   73,   78,   82,   87,   92 ,  98,   104,  110,  117,  123]
n2 = {"do":f2[0], "#do":f2[1], "re":f2[2], "#re":f2[3], "mi":f2[4], "fa":f2[5],
         "#fa":f2[6], "sol":f2[7], "#sol":f2[8], "la":f2[9], "#la":f2[10], "xi":f2[11]}
f3 = [131,  139,  147,  156,  165,  175,  185,  196,  208,  220,  233,  247]
n3 = {"do":f3[0], "#do":f3[1], "re":f3[2], "#re":f3[3], "mi":f3[4], "fa":f3[5],
         "#fa":f3[6], "sol":f3[7], "#sol":f3[8], "la":f3[9], "#la":f3[10], "xi":f3[11]}
f4 = [262,  277,  294,  311,  330,  349,  370,  392,  415,  440,  466,  494]
n4 = {"do":f4[0], "#do":f4[1], "re":f4[2], "#re":f4[3], "mi":f4[4], "fa":f4[5],
         "#fa":f4[6], "sol":f4[7], "#sol":f4[8], "la":f4[9], "#la":f4[10], "xi":f4[11]}
f5 = [523,  554,  587,  622,  659,  698,  740,  784,  831,  880,  932,  988]
n5 = {"do":f5[0], "#do":f5[1], "re":f5[2], "#re":f5[3], "mi":f5[4], "fa":f5[5],
         "#fa":f5[6], "sol":f5[7], "#sol":f5[8], "la":f5[9], "#la":f5[10], "xi":f5[11]}
f6 = [1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976]
n6 = {"do":f6[0], "#do":f6[1], "re":f6[2], "#re":f6[3], "mi":f6[4], "fa":f6[5],
         "#fa":f6[6], "sol":f6[7], "#sol":f6[8], "la":f6[9], "#la":f6[10], "xi":f6[11]}
f7 = [2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951]
n7 = {"do":f7[0], "#do":f7[1], "re":f7[2], "#re":f7[3], "mi":f7[4], "fa":f7[5],
         "#fa":f7[6], "sol":f7[7], "#sol":f7[8], "la":f7[9], "#la":f7[10], "xi":f7[11]}
f8 = [4186]
n8 = {"do":f8[0]}

# 时值定义:

这里目前只定义了二分音符/四分音符/八分音符,其中四分音符默认设置为0.5秒。

采样率的话是数字信号的点的密集程度,10000表示一秒里面采样1万次,理论上越高的话音质越好。

# 时值定义
sample_rate = 10000             # 信号采样频率
quarter_note = 0.5              # 一个四分音符的时长

# 三种音符的时值定义
note_duration = {"half":quarter_note*2, "quarter":quarter_note, "eighth":quarter_note/2}
# 三种音符对应生成三种时间轴
t_short = np.linspace(0, note_duration["eighth"], int(sample_rate * note_duration["eighth"]), endpoint=False)
t = np.linspace(0, note_duration["quarter"], int(sample_rate * note_duration["quarter"]), endpoint=False)
t_long = np.linspace(0, note_duration["half"], int(sample_rate * note_duration["half"]), endpoint=False)

# 编曲示例

正如前文所说,我并没有提供 I/O 接口,所以编曲是直接在程序中进行修改。有点折腾哈哈哈哈哈,但是现在也懒得加,等以后什么时候心血来潮再完善一下。

# 生日快乐歌
freqs = [n4["sol"], n4["sol"], n4["la"],  n4["sol"], n5["do"], n4["xi"], 
         n4["sol"], n4["sol"], n4["la"],  n4["sol"], n5["re"], n5["do"],
         n4["sol"], n4["sol"], n5["sol"], n5["mi"],  n5["do"], n4["xi"], n4["la"],
         n5["fa"],  n5["fa"],  n5["mi"],  n5["do"],  n5["re"], n5["do"]]
duration = len(freqs)          # 音频总持续时长

# 声音信号的生成

每个音符的时值定义就更折腾了(这下你知道为什么我把它叫做简易编曲程序了吧),直接在 if-elif-else 中进行定义。

以生日快乐歌为例,每一句“祝你生日快乐”的前两个音符是八分音符,然后第1/2/4句的最后一个音符是二分音符,其余都是四分音符。

# 生成信号
signal = []
for i in range(duration):
    if(i == 0 or i == 1 or i == 6 or i == 7 or i == 12 or i == 13 or i == 19 or i == 20):
        signal_i = func(freqs[i], t_short)
    elif(i == 5 or i == 11 or i == 25):
        signal_i = func(freqs[i], t_long)
    else:
        signal_i = func(freqs[i], t)
    
    # 振幅标准化
    max = np.max(np.abs(signal_i))
    signal_i = signal_i /  max * (2**15 - 1)

    signal.append(signal_i)

振幅标准化是为了不让其后续转为int16的时候,振幅超过2^15的部分直接被截断从而导致音色发生改变(即声音信号的图像形状发生改变)。

# 信号的处理与导出

# 把 信号样本 变成 一维数组
signal_combined = np.concatenate(signal)
# 再把数组中每个信号转为int16
signal_int = signal_combined.astype(np.int16)
# 最后将信号导出为Wav格式的音频文件(import scipy.io.wavfile as wavfile)
wavfile.write('output.wav', sample_rate, signal_int)

这里注释已经说的很清楚了,我不再多说)

# 程序运行

上面分段给出的程序实际上就已经是完整的程序(除了import部分)。按下F5之后,目录下就多出一个“output.wav”文件啦,双击即可播放。你将获得一首“马林巴”演奏的生日快乐歌!祝你生日快乐!(假如今天刚好是你生日)