www.youtube.com
PyQt5の習作として、PyQt5とPyAudioを使ったBPM計測アプリを作ってみました。
よくあるBPM計測器の仕様となっていて、ユーザが拍位置でボタンクリックやキータイピングをすることで、BPMを計測をすることができます。
インターフェースもアルゴリズムもシンプルなので、音楽アプリ制作の入門に良さげです。また、PyQt5やPyAudioのよい勉強になります。
それでは、実装の紹介について説明します。
アプリの設計
シンプルかつ実用的な仕様にするため、以下のように設計しました。
- インターフェース
- GUI表示
- BPMの測定値を表示
- BPMを計測するための、拍位置入力用ボタンを表示
- キーボード入力をしても、拍位置入力用ボタンが押されたこととする
- タッピング時に、音を鳴らす
- アルゴリズム
今回、インターフェースはPyQt5
,PyAudio
,アルゴリズムはNumPy
ベースで実装しました。
PyQt5 によるGUI作成
以下のPyQt5の関数を使いました。
QGridLayout()
- BPM表示部
QLCDNumber
と拍位置入力ボタンQPushButton
を設置
QLCDNumber
QPushButton
QWidget().keyPressEvent()
- キーボード入力で呼び出される
- 今回は、オーバライドして
QWidget().buttonClicked()
を呼ぶ
QWidget().buttonClicked()
PyAudoで、タッピング時に音を鳴らす
今回の実装では、AudioManager()
というPyAudio
のストリームと入力を管理するクラスを定義しています。
QWidget().buttonClicked()
が押されたときにAudioManager()
のsound()
を呼び出し、その中のstream.write()
で短い時間区間分サイン波を書き込むことで音を鳴らしています(実装では0.1秒程度)。
注意点としては、音を鳴らしている間もGUIでの操作ができるように、threading
を使って、stream.write()
を鳴らす関数を並列処理とします。
BPMは、ユーザがタッピングにより入力した拍位置を元に計測しています。
BPMは、その名の通り、1分あたりの拍数(Beat Per Minute)を示します。従って、現在の拍の時刻と、1つの前の拍の時刻があれば計算できます。
具体的には、
- 現在の拍の時刻[sec]を取得
- 拍間の時刻差分[sec]を取得
- BPMを計算
- 60 / (拍間の時刻差分[sec])
- (1秒あたりの拍数)=1 / (拍間の時刻差分[sec])
- (1分あたりの拍数)=60 * (1秒あたりの拍数)
4.(1つ前の拍の時刻)に(1つ前の拍の時刻)の値をセット
という流れでBPMを算出します。
ただし、上記の実装だけだと、ユーザの拍の入力が安定しない場合には、BPMの算出も安定しません。
そこで、今回の実装では2つの工夫をしています。
- BPM を算出時に、拍間の時刻差分[sec]を鈍らせる
- α * (現在の拍間の時刻差分[sec]) + (1.0 -α) * (1つ前の拍間の時刻差分[sec])
- αは(0.0, 1.0]のスカラー
- αは、値が小さいほど一つ前のBPM値に近づける効果がある
- BPM算出後に、BPMの平均を取る
import sys
import time
import pyaudio
import threading
import numpy as np
from PyQt5.QtWidgets import QWidget, QPushButton, QApplication, QGridLayout, QLCDNumber
from PyQt5.QtGui import QKeySequence
class AudioManager():
def __init__(self, rate=44100, n_chunk=1024):
self.rate = rate
self.n_chunk = n_chunk
self.p = pyaudio.PyAudio()
self.stream = self.p.open(format=pyaudio.paFloat32, channels=1, rate=rate, output=1,
frames_per_buffer=n_chunk)
def __render(self, freq, duration, offset=0, gain=0.4):
pi2_t0 = 2 * np.pi / (self.rate / freq)
N = (self.rate * duration) // self.n_chunk
while self.stream.is_active() and N > 0:
x = np.arange(offset, offset + self.n_chunk)
chunk = gain * np.sin(pi2_t0 * x)
self.stream.write(chunk.astype(np.float32).tostring())
offset += self.n_chunk
N -= 1
return True
def sound(self, freq=440, duration=0.1):
t = threading.Thread(target=self.__render, args=(freq, duration))
t.start()
class BPM():
def __init__(self, alpha=0.8):
self.diff_pre = 0.5
self.time_pre = 0.0
self.counter = 0
self.alpha = alpha
self.n_buff = 8
def __reset(self):
self.counter = 0
self.diff_pre = 0.5
return 120
def __calc(self):
time_cur = time.time()
if self.counter > 0:
diff = time_cur - self.time_pre
if diff > 3.0:
bpm = self.__reset()
else:
if diff > 1.0:
diff = 1.0
elif diff < 0.20:
diff = 0.20
diff = self.alpha * diff + \
(1.0 - self.alpha) * self.diff_pre
bpm = 60 / diff
self.diff_pre = diff
else:
bpm = self.__reset()
self.time_pre = time_cur
return bpm
def count(self):
counter = self.counter
bpm = self.__calc()
idx = counter % self.n_buff
if counter == 0:
self.bpms = np.array([bpm for k in range(self.n_buff)])
else:
self.bpms[idx] = bpm
bpm_mean = int(np.mean(self.bpms)*10)//10
self.counter += 1
return bpm_mean, counter
class MyWidget(QWidget):
def __init__(self):
super().__init__()
self.bpm = BPM()
self.am = AudioManager()
self.bpm_list = None
self.Init_UI()
self.show()
def Init_UI(self):
self.setGeometry(100, 100, 250, 250)
self.setWindowTitle('BPM Tap')
grid = QGridLayout()
self.lcd = QLCDNumber()
grid.addWidget(self.lcd, 0, 0)
grid.setSpacing(10)
button = QPushButton("tap")
grid.addWidget(button, 1, 0)
button.clicked.connect(self.buttonClicked)
self.setLayout(grid)
def buttonClicked(self):
bpm, count = self.bpm.count()
c = count % 4
if count == 0:
self.bpm_list = [0, 0, 0, 0]
self.bpm_list[c] = bpm
freq = 880 if c==0 else 440
self.am.sound(freq=freq)
print(f"{c+1}/4 Tap: {bpm}")
self.lcd.display(f'{bpm}')
def keyPressEvent(self, event):
key = QKeySequence(event.key()).toString()
self.buttonClicked()
print(f"\tInput key: {key}")
if __name__ == '__main__':
app = QApplication(sys.argv)
w = MyWidget()
app.exit(app.exec_())
github.com
