Wizard Notes

Python, JavaScript を使った音楽信号分析の技術録、作曲活動に関する雑記

Python: LibROSAによるBPM自動算出の詳細 

はじめに

この記事では、Python向けの音楽信号分析モジュールである LibROSA
実装されているBPMの自動算出手法について、Pythonのコードをベースに解説します。

BPM自動算出の概要・設計の方針については、以下の記事をご参考ください。

www.wizard-notes.com

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.beat.tempo Examplesより

  1. librosa.load()を使って、楽曲の時間信号yとサンプリング周波数srを得る
  2. librosa.onset.onset_strength関数により、onset strength envelope(発音強度包絡)onset_envを得る
  3. librosa.beat.tempo()onset_envを与え、BPM tempoが算出される

楽曲信号には、リズムを分析するのに不必要/邪魔な成分が含まれています(調波楽器のアタック音以外や、反響・残業など)。そこで、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 = Slag = 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)

librosa.beat.onset_strength()

以上の処理により、入力された楽曲信号の対数パワースペクトログラムSの時間フレーム方向の差分の、周波数平均を計算しています。

これは音楽分析でよく使われる、 Spectral flux と呼ばれる特徴量です。ただし、Spectral fluxの負の成分は0としています。

語弊を恐れず言えば、急激に音圧が大きくなる時間フレームで大きい値を取ります。つまり、```onset_env````は発音の強さを表す特徴量の時間系列となっています。

LibROSAのBPM検出では、このSpectral flux を元にBPMを算出します。この周辺技術については、以下のドキュメントが参考になります。

librosa.github.io

librosa.beat.tempo()について

librosa.feature.tempogram で算出されるテンポグラムを元に(グローバルな)BPMが算出されます。

テンポグラムについては、以下の記事をご参照ください。

www.wizard-notes.com

テンポグラム行列tgの形状は(win_length, len(onset_envelop))であり、1次元目がBPMに対応する周波数(以降、BPM周波数)となっています。テンポグラムは自己相関関数により算出された局所的なテンポ変化を捉えるためのリズム特徴量であり、端的に言えば、各時間フレームでどのBPM周波数っぽいかを表しています。

処理の流れを示します。

大まかな流れとしては、時間フレームごとのBPM周波数(テンポグラム)を算出し、 時間平均+重み付けを行うことで、楽曲全体のBPMを算出しています。 (局所的なBPM周波数をサンプルとしたMAP推定っぽい感じ)

  1. onset_envelopeより、tempogram関数よりテンポグラムtgを算出
  2. tgを時間方向に集約(平均)する
  3. tgBPM周波数のインデックスに対応するBPMが格納されたベクトルbpmsを取得
  4. 事前確立(重み付け)ベクトルの負の対数であるlogpriorを作成
  5. logpriorを考慮して、最も楽曲のBPMらしい、BPM周波数インデックスbest_periodを算出
  6. best_periodbpmsインデックスとして与え、推定された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]

librosa.beat

BPM の事前分布(事前知識としてどのBPMっぽいかという情報、または重み付け)であるlogpriorは、デフォルトでは下の図のような関数となっています。LibROSAの実装では、底が2の)対数正規分布を使っています。ここでは、start_bpm, std_bpm がそれぞれ BPM_mean,BPM_std に対応しています。

f:id:Kurene:20191025201111p:plain
上:BPMの対数正規分布 P(BPM),下:log2 P(BPM)

最後に

LibROSAのBPM算出は処理が少し複雑ですが、BPM自動算出の設計方針にも書いたように、拍検出がしやすい信号(Onset envelope)に対して周波数分析(テンポグラム)を行うことでBPMを推定しています。

使ってみた感じ、EDMなどビートが分かりやすい楽曲には十分な精度だと思います。 一方で、ジャズやピアノソロなど人間が聞いても拍が分かりにくい楽曲へ適用する際には、まだまだ改善の余地がありそうです。

更なる精度向上のためには、以下のようなチューニングが考えられます。

  • 周波数帯域への重み付け
  • 邪魔な成分の除去/抑圧
  • 時間フレームへの重み付け、取捨選択
    • ノイジーな(BPM算出が難しそうな)区間を捨てる/信頼性を重みづける
  • BPMの値を制限
    • 最小/最大のBPMを限定する
    • 解像度

補足

Onset-envelope, Tempogramのプロット

f:id:Kurene:20191014150022p:plain
図1: 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()