Python音楽制作 pt.2

前回に引き続きPythonで音楽をやろうとしています。

前回部分の改良

とりあえず作業に先立って前回時点からの改良として、毎回add_metronomeを走らせるのは無意味なので、インスタンス立ち上げ時に実行してもらうことにします。どちらにせよそれを再生時に鳴らすかどうかは最終的なplay()内のオプションで決めることなので。

よってクラス定義の際の__init__内でadd_metronomeを走らせておく。init内の引数にsubやsubsubが入ったことに注意。

class AudioDataFrame:
    def __init__(self, bpm = 120, bars = 1, sampling_freq=44100, sub=False, subsub=False):
        self.bpm = bpm
        self.bars = bars
        self.beats = 4 * bars
        self.seconds = 60 / bpm * self.beats
        self.secfreqperbeat = self.seconds * sampling_freq / self.beats
        self.sampling_freq = sampling_freq
        self.audio = self.generate_blank_signal()
        self.add_metronome(sub=sub, subsub=subsub)

audio = AudioDataFrame(bpm = 90, bars = 1, sub=True)
audio.play(metronome=True)
audio.show()

実際のオーディオを読み込んで合成する

今回はまずどこにでもある808のハイハット(ファイルサイズは13KB)を用意した。Googleドライブに突っ込んでおいてColabで読み出す。ささっと中身を確認するためには、たとえばwaveモジュールのgetparamsあたりで確認できる。

import wave
f = '/content/drive/MyDrive/PyMusic/hihat.wav'
w = wave.open(f)
print(w.getparams())

パラメータをざっと確認すると、モノラルで、16ビット、44100Hz、3954フレーム(=フレームレートで割るとファイルの長さは0.089659864秒)ですね。前回からずっとサンプリング周波数44.1kHz、明らかにモノラルでやっているのでこのままいきます。

とはいえwavファイルはバイナリデータなので、そのまま読み出しても使えない。numpyのndarrayになるのがベストかな。バイナリの状態で中身が見たければ、

f = '/content/drive/MyDrive/PyMusic/hihat.wav'
with wave.open(f, 'rb') as w:
    w.setpos(0)
    s = w.readframes(nframes=w.getnframes())
print(s)

すれば、頭から最後までバイナリとして読み出してくれる。現にこんな感じ。

これをndarrayに変換したい。変換にあたってはこちらを参考にさせていただきました。正直今回はモノラル16ビットしか触らないので細かい拡張は必要に応じて将来的に。

ひとまず鳴らしてみる。floatにできた瞬間にIPythonのAudioに突っ込めば鳴るので、

import numpy as np
import wave
from IPython.display import Audio
f = '/content/drive/MyDrive/PyMusic/hihat.wav'
with wave.open(f, 'rb') as w:
    params = w.getparams()
    w.setpos(0)
    s = w.readframes(nframes=params[3])
    s = np.frombuffer(s, dtype=np.int16).astype(float)/(2**(8*params[1]-1))
Audio(s, rate=44100)

これでいい。これを前回のAudioDataFrame内で呼び出せるように改良していく。とりあえず将来的にキックやスネアも入れていきたいわけなので、dictでファイル一覧を作りつつ、指定した音の波形が返ってくる関数を作る。そして関数の内部にdrumkitという辞書を作ってしまったことを後悔する時がいつかくることだろうと思う。

    def load_drumsound(self, sound='CHH'):
        drumkit = {
            'CHH': '/content/drive/MyDrive/PyMusic/hihat.wav',
            'KICK': '/content/drive/MyDrive/PyMusic/kick.wav'
        }
        with wave.open(drumkit[sound], 'rb') as w:
            params = w.getparams()
            w.setpos(0)
            s = w.readframes(nframes=params[3])
            s = np.frombuffer(s, dtype=np.int16).astype(float)/(2**(8*params[1]-1))
        return s

あとはこいつを指定したタイミングでマスターの audio[‘y’] 上に組み込めば好きなタイミングで音が鳴るわけですわね。ぼちぼち午前2時なので一旦細かい説明なしで書き上げます。まずはfind_position関数を作って、こちらが指定したポジション(pos)、これは頭から数えて何拍目かを意味する数値変数ですが、それを周波数(つまり変数 t )の位置に変換してくれるものですね。roundして一番近所の整数に落ち着くようにしてあります。

    def find_position(self, pos):
        if pos == 0:
            return 0
        return np.round((self.sampling_freq * 60 / self.bpm) * pos)

そして hit_sound_in_the_specific_pont は、その名の通り指定したポイントで音を鳴らしたいわけです。いま上の関数で頭から何拍目かさえ指定すればいいことになったので、それをstart_beatsに指定します。ただしこの関数は中でいちいち該当する音源素材を読み出すようになっているので、一打ごとに読み出して追加しているとキリがない。ということで、start_beatsはリスト形式にしてあります。書いてから思ったけど0拍目スタートにしたのゴミですね。次回修正します。

volumeは0から1までの連続値で、sound * volumeしていることからも分かるように、馬鹿正直に音を小さくします。何もしないと元ファイルそのままの音量なのでクリップします。

    def hit_sound_in_the_specific_point(self, sound='CHH', start_beats = [0], volume=1):
        if (volume > 1) or (volume < 0):
            raise ValueError('Volume must be in 0 - 1.')
        audio = self.audio
        sound, fr = self.load_drumsound(sound=sound)
        for pos in start_beats:
            pos = self.find_position(pos)
            blank = self.generate_blank_signal().rename(columns={'y':'sound'}).drop(columns='t')
            blank.loc[pos:pos+fr-1, 'sound'] = sound * volume
            
            audio = audio.join(blank, how='outer').fillna(0)
            audio['y'] = audio['y'] + audio['sound']
            audio = audio.drop(columns='sound')
        self.audio = audio

ということで、この形で走らせてみると、

audio = AudioDataFrame(bpm = 90, bars = 1, sub=True)
audio.hit_sound_in_the_specific_point(start_beats=[0, 1, 2, 3], volume=0.3)
audio.play(metronome=True)
audio.show()

今回はBPM90の1小節、メトロノームを8分で打って、表拍でハイハットを打っています。

ドラムビートを作る

せっかくなので一気にレベルを上げて(メトロノームを切った上でスネアも追加しています)、

audio = AudioDataFrame(bpm = 90, bars = 1, sub=True)
audio.hit_sound_in_the_specific_point(sound='CHH', start_beats=[0, 0.333, 0.666, 1, 1.5, 2, 2.5, 3, 3.5, 3.75], volume=0.2)
audio.hit_sound_in_the_specific_point(sound='KICK', start_beats=[0, 1.75, 2.5], volume=0.3)
audio.hit_sound_in_the_specific_point(sound='SNARE', start_beats=[1, 3], volume=0.35)
audio.play(metronome=False)
audio.show()

もうリズム隊だけならこれだけで普通に打ち込みができるレベルですね。あと808のクラッシュでも追加すれば普段作ってる簡単なトラップビートぐらいできると思う。

ということで今日はここまで。次ぐらいからそろそろメロディ乗せていきたいなー。コード進行だけ指定したら自動で最低限曲ができるようなシステムにしたいなー。それぐらいなら難しくないはずだけど、最終的には簡単なソフトシンセとリバーブぐらいは作ってみたいもんだなあ。

明日も朝から会議なので寝ます。今日の進捗は以下のGistに。