Morikatron Engineer Blog

モリカトロン開発者ブログ

Pythonと音楽と...(2)トラックとミックス

こんにちは、モリカトロンのチーフエンジニア松原です。
連載第2回めの今回は、前回作成した音を鳴らすプログラムをもうすこしキチンとした実用的なプログラムに改造して行きます。具体的には、音楽を扱いやすいように「トラック」を導入し、音の切れ目のプチプチノイズを除去する仕組みも入れてみます。また、音名(C4とかそういうの)を指定してメロディを作り、複数トラックのミックス(合成)まで実装します。

トラックとは

ここで言うトラックは荷物を運ぶエンジンのついたクルマとかではなく、音楽を録音する際に使われる技術のことで、たとえばドラムとギターとボーカルをそれぞれ別の音声データ(=トラック)として録音しておけば、あとでトラックごとに音質や音量など様々な調整を施しながらミックス(合体)できて、たいへん便利です。
http://cnt.kingrecords.co.jp/studio/sekiguchidai/wp-content/uploads/2014/03/recording1-21.jpg 見渡す限りどこまでもツマミがならんでいるこの机の写真は関口台スタジオさんのHPから引用させていただきました。72chの入力が可能なミキサーコンソールのようです。72個のトラックを調整しながらミキシングできるのでしょうか。ちょう便利そうで、しかもカッコイイですね(ツマミさわりたい)。

前回の連載で紹介したプログラム sample2_play_buf.py では、音データを生成しながらストリームに順次渡していく方式で音を連続的に鳴らしていました。この方法では一度に1音しか鳴らせませんし、連続した音の並びに対してなにかの処理(たとえば音量の調整やエコーなどのエフェクトをかけるなど)を行いたい場合にたいへん不便です。そこで今回はまず「トラック」を導入しましょう。音楽の録音時に有効な「トラック」の考え方は、コンピュータ内部で音楽を生成・再生する場合にもたいへん有効なテクニックとなります。トラックとして波形を生成することでその上にどんどん音を合成したり(ピアノのように同時に何音も出せるようなポリフォニックな楽器のシミュレーション)、複数のパート(トラック)を生成してミックスすることで、複数のパートによる合奏が可能となります。

sample3_play_all.py

Numpyでメモリ上に一気に作ったサイン波のデータ(トラック)を、PyAudioのstreamで一気に再生するサンプルコードです。
https://github.com/morikatron/snippet/blob/master/python_and_music/sample3_play_all.py

import numpy as np  # install : conda install numpy
import pyaudio  # install : conda install pyaudio

# サンプリングレートを定義
SAMPLE_RATE = 44100


# 指定ノート番号のサイン波を、指定秒数生成してNumpy配列で返す関数
def notenumber2wave(notenumber: int, duration: float) -> np.array:
    # MIDIのノート番号を周波数に変換
    freq = 440.0 * 2 ** ((notenumber - 69) / 12)
    # 指定周波数のサイン波を指定秒数分生成
    samples = np.sin(np.arange(int(duration * SAMPLE_RATE)) * freq * np.pi * 2 / SAMPLE_RATE)
    # 波形の頭とお尻を最大100サンプル(約0.002秒)をフェード処理する(つなぎ目のプチノイズ軽減のため)
    fade_len = min(100, samples.size)  # フェード処理するサンプル数
    slope = (np.arange(fade_len) - 1) / fade_len  # フェードインのスロープ計算
    samples[:fade_len] = samples[:fade_len] * slope  # サンプル先頭とスロープを掛けてフェードイン
    slope = ((fade_len - 1) - np.arange(fade_len)) / fade_len  # フェードアウトのスロープ計算
    samples[-fade_len:] = samples[-fade_len:] * slope  # サンプル末尾とスロープを掛けてフェードアウト
    return samples


# PyAudio開始
p = pyaudio.PyAudio()

# ストリームを開く
stream = p.open(format=pyaudio.paFloat32,
                channels=1,
                rate=SAMPLE_RATE,
                frames_per_buffer=1024,
                output=True)

# ドミソドーの波形を作る
track = np.array([])
track = np.append(track, notenumber2wave(60, 0.3))  # note#60 C4 ドを追加
track = np.append(track, notenumber2wave(64, 0.3))  # note#64 E4 ミを追加
track = np.append(track, notenumber2wave(67, 0.3))  # note#67 G4 ソを追加
# l1 = track.size
track = np.append(track, notenumber2wave(72, 0.6))  # note#72 C5 ドを追加

# 再生
stream.write(track.astype(np.float32).tostring())

# ストリームを閉じる
stream.close()

# PyAudio終了
p.terminate()

# 47行目あたりにあるl1 = track.sizeで記録しておいた位置を中心にグラフをプロットする(波形のつなぎ目で見たい場合)
# import matplotlib.pyplot as plt
# plt.figure(figsize=(6,3))
# plt.plot(track[l1 - 200:l1 + 200])
# plt.show()

notenumber2wave()は一つの音データを生成する関数です。この関数に(60, 0.3)を渡すとMIDIのノート番号60(C4のドの音)で0.3秒間の波形を生成します。続けて(64, 03)でミ、(67, 0.3)でソ、(72, 0.6)で高いドの音を生成し、これらをNumpyのnp.append()で連結することで、一つのトラック(Numpy配列のtrack変数)にドミソドーのメロディを生成できます。 このtrackをPyAudioのstreamにwrite()することで、メロディが鳴る仕組みとなっています。

プチプチノイズの除去

さて、このnotenumber2wave()ではnp.sin()でサイン波を生成しますが、生成した複数の波形をシンプルに連結すると、波形のつなぎ目に急激な値の変化が現れます。その波形をグラフで描くと、次のような具合になります。
f:id:morika-ma2:20200728150506p:plain グラフ中央の200サンプル目に、不自然なつなぎ目が見えると思います。ここの数値は[... -0.48, -0.53, 0, 0.07, 0.15 ...]となっており、-0.53から0へと急に変化しています。値に急激な変化のあるこのような波形を再生すると「プチッ」というたいへん耳障りなノイズが出てしまいますので、ここはぜひ「なだらか」につなぎたいものです。
そこで、波形の先頭と末尾の部分を、なだらかに0に向かって減衰させ(つまりフェードイン・フェードアウトをかけ)ることで、どのような波形同士を接続したときでも、値0付近でなだらかに接続できるようにしましょう。上のサンプルコードのように先頭と末尾それぞれ100サンプルをフェードイン・フェードアウトさせると、つぎのような波形になります。 f:id:morika-ma2:20200728153244p:plain 値の急激な変化は抑制され、値0付近でなだらかに接続されていることがわかります。
このあたりのグラフを自分でも表示してみたい方は、上のサンプルコードのコメントになっている部分(l1 = track.sizeとかimport matplotlib.pyplot as plt以下あたり)を実行してください。その際はconda install matplotlibとかで必要なライブラリをインストールしてやってください。

トラックのミックス

上記のプログラム sample3_play_all.py では、トラックは1つしか作っていません。なので楽器としては一個だけ、といえます。たとえば三つの楽器で同時に音を出したい場合には、トラックを3つ作成して、それをミックスする必要があります。ミックスは具体的には、複数のトラックの個々のサンプル間の平均を計算することで実現できます。たとえば
波形1 [0.1, 0.2, 0.3, 0.4...]
波形2 [0.3, 0.4, 0.5, 0.6...]
このふたつをミックスするには、ここのサンプルの平均を取ります。すなわち
波形3 [(0.1+0.3)÷2=0.2, (0.2+0.4)÷2=0.3, (0.3+0.5)÷2=0.4, (0.4+0.6)÷2=0.5...]といったように計算すれば良いわけです。幸いいま我々が使っているPythonのNumpyというライブラリを使うと、トラックのミックスなんぞチョチョイのチョイでできてしまいます。サンプルを見てみましょう。

sample4_play_all_tracks.py

3つのトラックを生成し、ミックスして再生するサンプルコードです。 https://github.com/morikatron/snippet/blob/master/python_and_music/sample4_play_all_tracks.py

import numpy as np  # install : conda install numpy
import pyaudio      # install : conda install pyaudio

# サンプリングレートを定義
SAMPLE_RATE = 44100


# 指定ノート番号のサイン波を、指定秒数生成してNumpy配列で返す関数
def notenumber2wave(notenumber: int, duration: float) -> np.array:
    # MIDIのノート番号を周波数に変換
    freq = 440.0 * 2 ** ((notenumber - 69) / 12)
    # 指定周波数のサイン波を指定秒数分生成
    samples = np.sin(np.arange(int(duration * SAMPLE_RATE)) * freq * np.pi * 2 / SAMPLE_RATE)
    # 波形の頭とお尻を最大100サンプル(約0.002秒)をフェード処理する(つなぎ目のプチノイズ軽減のため)
    fade_len = min(100, samples.size)  # フェード処理するサンプル数
    slope = (np.arange(fade_len) - 1) / fade_len  # フェードインのスロープ計算
    samples[:fade_len] = samples[:fade_len] * slope  # サンプル先頭とスロープを掛けてフェードイン
    slope = ((fade_len - 1) - np.arange(fade_len)) / fade_len  # フェードアウトのスロープ計算
    samples[-fade_len:] = samples[-fade_len:] * slope  # サンプル末尾とスロープを掛けてフェードアウト
    return samples


# 音名(C5とかC#5とか)のサイン波を、指定秒数生成してNumpy配列で返す関数
def name2wave(name: str, duration: float) -> np.array:
    notenumber: int = 0  # 該当する音名がなかった場合は無音区間とする
    if len(name) > 0:
        name = name.upper()
        # 末尾の数字(-1 ~ 9)を得る。数字がなければ5とする。
        octave = 5
        if name[-2] == "-1":
            octave = -1
            name = name[:-2]  # 後ろ2文字を削除
        elif name[-1].isdigit():
            octave = int(name[-1])
            name = name[:-1]  # 後ろ1文字を削除
        names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
        for i, n in enumerate(names):
            if name == n:
                # 音名とオクターブからノート番号が確定
                notenumber = (octave + 1) * 12 + i
                break
    return notenumber2wave(notenumber, duration)


# トラック=[(音名,長さ)の配列]をwaveに変換してNumpy配列で返す関数
def notes2track(notes: list) -> np.array:
    arr = np.array([])
    for note in notes:
        arr = np.append(arr, name2wave(note[0], note[1]))
    return arr


# n個の波形の長さを揃えて縦に積む関数
def padding_and_stack(inputs: list) -> np.array:
    # 配列の総数が0なら、返すものがない
    if len(inputs) < 1:
        return None
    # 配列の総数が1なら、その1個を返す
    if len(inputs) == 1:
        return inputs[0]
    # 配列の最大要素数を調べる
    maxlen = 0
    for x in inputs:
        if maxlen < len(x):
            maxlen = len(x)
    # 最大数に揃える(うしろに0を加える)
    for i, x in enumerate(inputs):
        if len(x) < maxlen:
            inputs[i] = np.pad(x, (0, maxlen-len(x)))
    # 縦に積む
    return np.vstack(inputs)


# PyAudio開始
p = pyaudio.PyAudio()

# ストリームを開く
stream = p.open(format=pyaudio.paFloat32,
                channels=1,
                rate=SAMPLE_RATE,
                frames_per_buffer=1024,
                output=True)

# トラック3つ(ドレミファソー + ミファソラシー + ソラシドレー)の波形を作成
track1 = notes2track([("C4", 0.3), ("D4", 0.3), ("E4", 0.3), ("F4", 0.3), ("G4", 0.6)])
track2 = notes2track([("E4", 0.3), ("F4", 0.3), ("G4", 0.3), ("A4", 0.3), ("B4", 0.6)])
track3 = notes2track([("G4", 0.3), ("A4", 0.3), ("B4", 0.3), ("C5", 0.3), ("D5", 0.6)])

# 3つのトラックの長さを揃えて縦に積む
tracks = padding_and_stack([track1, track2, track3])

# 1つの波形に合成(個々のサンプルの平均を取る)=ボリューム1:1:1のミキシング
mixed = tracks.mean(axis=0)

# 再生
stream.write(mixed.astype(np.float32).tostring())

# ストリームを閉じる
stream.close()

# PyAudio終了
p.terminate()

以下に、本サンプルの特徴というか加筆した部分を説明します。

  • notenumber2wave関数は前と変わらずですが、ノート番号を指定するのは人間にはちょっとしんどい(わからぬ)ので、音名(C4とかそういう)で指定できるようにしたのが、name2wave関数です。name2waveには音名C,D,E,F,G,A,Bに加えて半音アゲ記号の#、オクターブを指定する-1~9を書くことで、特定の周波数を指定できるようになっています。name2wave関数は内部で音名をMIDIのノート番号に変換して、notenumber2wave関数を呼び出しています。

  • note2track関数は、(音名+長さ)のリストを受け取り、全波形を合成&連結して返す関数。トラックを構成する全音符をリストで渡すだけで良いわけです。

  • padding_and_stack関数は、渡されたリスト内の全トラックの長さを揃えて(短いトラックには末尾を0で埋める)、配列を縦に積んで返します。

  • ドレミファソー、ミファソラシー、ソラシドレーの三つのトラックを音名のリストで作成し、縦に積んだあと、Numpyのmean関数を呼び出すことで各サンプルの平均値を算出した新トラック(ミックスされたあとの波形)を生成、streamにwrite()で再生しています。

ということで

今回のプログラムでトラックの導入とミキシングができるようになりました。次回はこのプログラムを利用してMIDIファイルを読み込み、再生してみましょう。