Pythonで撥弦音を合成してみた

音の合成をいままでしたことがなかったので、撥弦音を合成してみました。今回は、音の合成にKarplus-Strong方式を使用しております。

合成方法

(1) 周波数 \(f\) によって、遅延数 \(p\) を以下のように決定。

$$ p = \frac{f_s}{f}-0.5 $$

\(f_s\) はサンプリング周波数です。また、\(p\) は整数でないといけないので整数に変換します。

(2) \(x_0 \cdots x_{p-1}\) に乱数(白色雑音)を入れる。\(x_n (n\geq p)\) には0を入れる。

(3) 以下のデジタルフィルタに通す。

図:撥弦音合成のためのデジタルフィルタ
図:撥弦音合成のためのデジタルフィルタ

数式で書くと以下の式のようになります。

$$
\newcommand{\sb}[1]{_{#1}}
y\sb{n}=\frac{1}{2}(y\sb{n-p}+y\sb{n-p-1})+x\sb{n} $$

プログラム

Karplus Strong 方式でドレミファソラシドを弾くプログラムを作りました。

import sys
import scipy.signal as sg
import numpy as np
import soundfile as sf

# コマンドラインの引数を取得
args = sys.argv
if len(args)!=3:
    print("\nusage: python Karplus.py amp filename");
    print("   amp        : amplitude");
    print("   filename   : file name of sound");
    raise Exception("Argument error ")

amp = float(args[1]) # 初期値の振幅範囲
filename = args[2]   # ファイル名

# ドレミファソラシド
freq = [261.626, 293.665, 329.628, 349.228, 391.995, 440.0, 493.883, 523.251]
fs = 48000               # サンプリング周波数
interval = int(fs*0.5)   # 音符が変化する間隔
n_data = interval*8      # データの数
wave = np.zeros(n_data)  # 波形データを入れる場所 

for i, f in enumerate(freq):

    # 乱数生成
    p = int(fs/f-0.5)
    n = i*interval
    wave[n:n+p] = (np.random.rand(p)-0.5)*amp

    # デジタルフィルタに通す
    b = np.ones(1)
    a = np.zeros(p+2)
    a[0] = 1.0
    a[p:p+2] = -0.5
    wave[n:n+interval] = sg.lfilter(b, a, wave[n:n+interval])

sf.write(filename, wave, fs, subtype='PCM_16')

撥弦音の合成結果

上記のプログラムで作成した撥弦音を確認しました。合成した撥弦音が以下です。

合成した撥弦音

ドレミファソラシドの弦の音になっているのが確認できると思います。

波形とスぺクトログラムは以下のような感じです。

図:合成した撥弦音のスペクトログラム
図:合成した撥弦音のスペクトログラム

弦楽器の特徴である調波構造があり、調波成分の減衰の仕方とかも撥弦音のような感じがします。ただ、初期値の白色雑音が少し違和感ありますね。

MIDIを演奏するプログラム作成

midiデータを解析するmidoを用いて、合成した撥弦音で演奏させるプログラムを作成してみました。

プログラム

プログラムは以下です。処理が複雑になるため、MIDIメッセージはノートオンとノートオフ以外は無視したものとなっています。

import sys
import scipy.signal as sg
import numpy as np
import soundfile as sf
from mido import MidiFile

# 弦の音を生成する関数
def gen_string(freq, amp, time_n, fs):

    # 乱数生成
    p = int(fs/freq-0.5)

    wave = np.zeros(time_n)
    if time_n < p:
        return wave
    wave[:p] = (np.random.rand(p)-0.5)*amp

    # デジタルフィルタに通す
    b = np.ones(1)
    a = np.zeros(p+2)
    a[0] = 1.0
    a[p:p+2] = -0.5
    wave = sg.lfilter(b, a, wave)

    return wave

# コマンドラインの引数を取得
args = sys.argv
if len(args)!=2:
    print("\nusage: python Karplus.py amp filename")
    print("   filename   : file name of mid")
    raise Exception("Argument error ")

# ファイル名
filename = args[1]   
mid = MidiFile(filename)

ch_list   = []     # チェンネル番号のリスト
pos_list  = []     # 開始時間のリスト
note_list = []     # 音符のリスト
velocity_list = [] # ベロシティのリスト
time_list = []     # 音符の長さのリスト

fs = 48000      # サンプリング周波数
abs_time = 0.0  # 演奏開始からの経過時間

# MIDIのメッセージを受け取ってリストの作成
for msg in mid:

    # 経過時間の更新
    abs_time += msg.time

    # 打鍵を押したとき
    if msg.type == 'note_on':

        # ベロシティが0のとき
        if msg.velocity==0:
            l = len(note_list)
            for i in reversed(range(0,l)):
                # ノート番号とチャンネル番号が一致したとき
                if note_list[i] == msg.note and ch_list[i] == msg.channel:
                    time_list[i] = abs_time-pos_list[i]
                    break
        else:
            ch_list.append(msg.channel)  # チャンネル番号記録
            pos_list.append(abs_time)    # 開始位置記録
            note_list.append(msg.note)   # 音符記録
            velocity_list.append(msg.velocity) # ベロシティ記録
            time_list.append(0.0)        # 音符の長さの記録

    # 打鍵を話したとき
    elif msg.type == 'note_off':
        l = len(note_list)
        for i in reversed(range(0,l)):
            if note_list[i] == msg.note and ch_list[i] == msg.channel:
                time_list[i] = abs_time-pos_list[i]
                break

# 空の波形データ作成
n_data = int(abs_time*fs)
wave = np.zeros(n_data)

# numpy配列に変換
pos_list  = np.array(pos_list)
note_list = np.array(note_list)
velocity_list = np.array(velocity_list)
time_list = np.array(time_list)

# 変換
pos_np  = pos_list*fs                # 時間からサンプル番号に変換
pos_np  = pos_np.astype(np.int32)    # 整数に変換
freq_np = 440*2**((note_list-69)/12) # ノート番号から周波数に変換
amp_np  = velocity_list/127          # ベロシティから振幅値に変換
time_np = time_list*fs               # 時間からサンプル番号に変換
time_np = time_np.astype(np.int32)   # 整数に変換

# リストから波形生成
for i in range(len(pos_np)):
    pos  = pos_np[i]
    freq = freq_np[i]
    amp  = amp_np[i]
    time_n = time_np[i]

    if time_n != 0:
        gen_wave = gen_string(freq, amp, time_n, fs)
        wave[pos:pos+time_n] = wave[pos:pos+time_n] + gen_wave

# wavファイルに書き込む
sf.write(filename[:-4]+".wav", wave, fs, subtype='PCM_16')

演奏してみた

ヨハン・パッヘルベルのカノンを演奏してみました。MIDIデータについてはこちらのサイトが提供しているMIDIデータを使わせていただいております。

演奏したヨハン・パッヘルベルのカノン

想像以上に上手く演奏できているので、自分でも驚いています。

おわりに

今回は、撥弦音の合成を試してみました。簡易的な方法で再現度の高い撥弦音を合成できるのは驚きました。原理についてはまたの機会に調べようと思います。また、他の合成音についても作成してみたいと思います。

参考文献
[1] 小坂 直敏、「サウンドエフェクトのプログラミング―Cによる音の加工と音源合成」、オーム社、2012.
[2] K.Karplus and A. Strong, “Digital Synthesis of Plucked-String and Drum Timbres”, Computer Music Journal, vol. 7, No. 2, pp. 43–55, MIT Press, 1983.