はじめに
この記事では、Python向けの音楽信号分析モジュールである LibROSAで
実装されているBPMの自動算出手法について、Pythonのコードをベースに解説します。
BPM自動算出の概要・設計の方針については、以下の記事をご参考ください。
LibROSAのBPM自動算出の詳細
LibROSA によるBPM算出
# Estimate a static tempo y, sr = librosa.load(librosa.util.example_audio_file()) onset_env = librosa.onset.onset_strength(y, sr=sr) tempo = librosa.beat.tempo(onset_envelope=onset_env, sr=sr) >>> tempo array([129.199])
librosa.load()
を使って、楽曲の時間信号y
とサンプリング周波数sr
を得るlibrosa.onset.onset_strength
関数により、onset strength envelope(発音強度包絡)onset_env
を得るlibrosa.beat.tempo()
にonset_env
を与え、BPMtempo
が算出される
楽曲信号には、リズムを分析するのに不必要/邪魔な成分が含まれています(調波楽器のアタック音以外や、反響・残業など)。そこで、BPM算出などのリズムの分析の際には、まず楽曲信号から発音強度の時間系列(=発音されたタイミング)を算出し、それを使って分析を行うのが一般的です。
librosa.onset.onset_strength() について
以下の論文で提案されている SuperFlux というOnset検知手法の一部をベースにしています。
(SuperFluxでは、さらに Peak-picking と呼ばれる識別処理により、Onsetの時間フレームか否かの2クラスに分類します。)
Maximum filter vibrato suppression for onset detection - DAFx-13
実は中身はonset_strength_multi()
です。
コアとなる処理部のコードを見てみましょう。
※デフォルトでは、ref = S
、lag = 1
です。
# Compute difference to the reference, spaced by lag onset_env = S[:, lag:] - ref[:, :-lag] # Discard negatives (decreasing amplitude) onset_env = np.maximum(0.0, onset_env) ... if aggregate is None: aggregate = np.mean ... if aggregate: onset_env = util.sync(onset_env, channels, aggregate=aggregate, pad=pad, axis=0)
以上の処理により、入力された楽曲信号の対数パワースペクトログラムS
の時間フレーム方向の差分の、周波数平均を計算しています。
これは音楽分析でよく使われる、 Spectral flux と呼ばれる特徴量です。ただし、Spectral fluxの負の成分は0としています。
語弊を恐れず言えば、急激に音圧が大きくなる時間フレームで大きい値を取ります。つまり、```onset_env````は発音の強さを表す特徴量の時間系列となっています。
LibROSAのBPM検出では、このSpectral flux を元にBPMを算出します。この周辺技術については、以下のドキュメントが参考になります。
librosa.beat.tempo()
について
librosa.feature.tempogram
で算出されるテンポグラムを元に(グローバルな)BPMが算出されます。
テンポグラムについては、以下の記事をご参照ください。
テンポグラム行列tg
の形状は(win_length, len(onset_envelop))
であり、1次元目がBPMに対応する周波数(以降、BPM周波数)となっています。テンポグラムは自己相関関数により算出された局所的なテンポ変化を捉えるためのリズム特徴量であり、端的に言えば、各時間フレームでどのBPM周波数っぽいかを表しています。
処理の流れを示します。
大まかな流れとしては、時間フレームごとのBPM周波数(テンポグラム)を算出し、 時間平均+重み付けを行うことで、楽曲全体のBPMを算出しています。 (局所的なBPM周波数をサンプルとしたMAP推定っぽい感じ)
onset_envelope
より、tempogram
関数よりテンポグラムtg
を算出tg
を時間方向に集約(平均)するtg
のBPM周波数のインデックスに対応するBPMが格納されたベクトルbpms
を取得- 事前確立(重み付け)ベクトルの負の対数である
logprior
を作成- デフォルトでは、対数正規分布
logprior
を考慮して、最も楽曲のBPMらしい、BPM周波数インデックスbest_period
を算出best_period
をbpms
インデックスとして与え、推定されたBPMを返り値とする
以下、librosa.beat.tempo()
のコードの重要な部分のみを引用します。
... # (1) tg = tempogram(y=y, sr=sr, onset_envelope=onset_envelope, hop_length=hop_length, win_length=win_length) # (2) Eventually, we want this to work for time-varying tempo if aggregate is not None: tg = aggregate(tg, axis=1, keepdims=True) # (3) Get the BPM values for each bin, skipping the 0-lag bin bpms = core.tempo_frequencies(tg.shape[0], hop_length=hop_length, sr=sr) # (4) Weight the autocorrelation by a log-normal distribution if prior is None: logprior = -0.5 * ((np.log2(bpms) - np.log2(start_bpm)) / std_bpm)**2 else: logprior = prior.logpdf(bpms) ... # (5) Get the maximum, weighted by the prior # Using log1p here for numerical stability ...(5) best_period = np.argmax(np.log1p(1e6 * tg) + logprior[:, np.newaxis], axis=0) # (6) return bpms[best_period]
BPM の事前分布(事前知識としてどのBPMっぽいかという情報、または重み付け)であるlogprior
は、デフォルトでは下の図のような関数となっています。LibROSAの実装では、底が2の)対数正規分布を使っています。ここでは、start_bpm
, std_bpm
がそれぞれ BPM_mean,BPM_std に対応しています。
最後に
LibROSAのBPM算出は処理が少し複雑ですが、BPM自動算出の設計方針にも書いたように、拍検出がしやすい信号(Onset envelope)に対して周波数分析(テンポグラム)を行うことでBPMを推定しています。
使ってみた感じ、EDMなどビートが分かりやすい楽曲には十分な精度だと思います。 一方で、ジャズやピアノソロなど人間が聞いても拍が分かりにくい楽曲へ適用する際には、まだまだ改善の余地がありそうです。
更なる精度向上のためには、以下のようなチューニングが考えられます。
補足
Onset-envelope, Tempogramのプロット
import librosa import numpy as np import matplotlib.pyplot as plt y, sr = librosa.load(librosa.util.example_audio_file(), offset=15, duration=15) onset_env = librosa.onset.onset_strength(y, sr=sr) #tempo = librosa.beat.tempo(onset_envelope=onset_env, sr=sr) ac_size=8.0 hop_length=512 win_length = librosa.core.time_to_frames(ac_size, sr=sr, hop_length=hop_length).item() tg = librosa.feature.tempogram(y=y, sr=sr, onset_envelope=onset_env,hop_length=hop_length, win_length=win_length) plt.subplot(3,1,1) plt.plot(y) plt.title("Audio signal") plt.subplot(3,1,2) plt.plot(onset_env) plt.title("Onset envelope: onset_env") plt.subplot(3,1,3) plt.imshow(tg, aspect="auto") plt.title("Tempogram: tg") plt.tight_layout() plt.show()