プログラミング習得への道のり #11 メトロノーム機能をつける ~PCキーボードで演奏したい~
プログラミング完全初心者の私が、プログラミングを習得するまでの全過程(主に悩み…)を記事にしていきます。
プログラミング学習にあたり、以下の順で進めています。
①アウトプットを決める、②基礎を学習する、③アウトプットを作り始める
アウトプットは PCキーボードで演奏できるアプリケーション に決めました。
前回までの記事で “TKinterを用いて画面(GUI部分)をカスタマイズする” にチャレンジし、BPMをスケールバーで変更できるウィジェットや、押したキーを表示するウィジェットを追加することができました。
「ステップ1.キーボード入力で音を鳴らす」に足りない要素として、メトロノーム機能があります。また、課題として、キープレスして音声が流れてからグラフがプロットされるまでにラグがあることや、キーを連続で押したときに音声が途切れてしまうことがあげられます。そこで本記事では、 “TKinterを用いてメトロノーム機能をつけ、残課題を解消する“ にチャレンジしました。
足りない要素
- メトロノーム機能をつけること
残課題
- キープレス、音声出力、グラフプロット間のリアルタイム性
- 音声出力の連続性
PCキーボード演奏アプリの作成ステップ
- キーボード入力で音を鳴らす
☑1−1.1つの音を鳴らす
☑1-2.音階を鳴らす
☑1-3.音の長さを自由に変える
☑1-4.画面をつくる
1-5.メトロノーム機能をつくる - 音を制御する
2-1.波形を変える
2-2.フィルタリング機能を追加する
2-3.複数の音を同時に鳴らす - 外部から音を取り込む
3-1.単音の音源サンプルから音を取り込む(1楽器)
3-2.音源から楽器を取り出す - 機能を追加する
4-1.録音&再生する(パターンをつくる)
4-2.外部と連携する - 曲を演奏する
- 曲を作る
☑︎本記事の内容
- 必要な材料:メトロノーム機能をつける、リアルタイム性および連続性を調整する
- レシピ①:音の連続性を調整する
- レシピ②:スレッドを用いてメトロノーム機能をつける
- レシピ③:スレッド、キューを用いてリアルタイム性を調整する
☑︎著者の経験
この記事を書いている私は、非情報系の大学院を卒業後、通信関係の企業に3年勤めた後、現在までコンサルティング会社に勤めています。
学生時代〜現職までプログラミングとは縁がなく、前職でルーターの設定を少しかじった程度のプログラミング完全初心者です。
こういった私が、解説していきます。
Twitterもはじめました。
目次
必要な材料:GUI部分の作成
○ライブラリ
今回使用するライブラリは以下です。
使用したライブラリ
- numpy:数値演算用ライブラリ → sin波を作る
- PyAudio:オーディオ関連ライブラリ → 音を鳴らす
- matplotlib:グラフ作成ライブラリ → 波形をプロットする
- Tkinter:GUI作成ライブラリ → 画面をつくる
レシピ①:音の連続性を調整する
まずは、今ある知識で修正できる部分から着手しました。
現状、キーを連続して入力すると、1音1音途切れてしまいます。
Tkinterを使う前のpygameを使っていたときは、滑らかに音が出力されていました。
ライブラリを変えた影響?画面を作った影響?とも考えましたが、音声出力部分のpyaudioに関する記載を見直しました。
○結果
下記のコードで滑らかに音が鳴るようになりました。
以下、メモです。
音声を滑らかに出力させるための改善として、以下二点実施しました。詳しくは「課題と解決方法」に記載します。
- pyaudioの見直し
- サイン波の開始値および終了値の見直し
○課題と解決方法
pyaudioの見直し
1音1音途切れてしまう原因は、play()メソッドにありました。
以前の記事でpyaudioで音声を出力するためには、以下のプロセスが必要と紹介しました。
①PyAudioインスタンスを作成
②ストリームを開く
③ストリームにデータを書き込み&再生する
④ストリームを閉じる&インスタンスを破棄する
ここで、変更前のコードではplay()メソッドで②〜④を定義していました。
そのため、毎回ストリームを閉じてからストリームを開いているため、1音1音途切れるとわかりました。
よって、アプリ起動中はストリームを開いたままに変更したところ改善しました。
# 変更前
def play(self):
self.stream = self.p.open(format=pyaudio.paFloat32,
channels=1,
rate=self.rate,
frames_per_buffer=self.chunk_size,
output=True)
self.stream.write(self.sin_t.astype(np.float32).tobytes())
self.stream.close()
# 変更後
def __init__(self, master=None):
…
#=== pyaudioの開始
self.p = pyaudio.PyAudio()
#==== 移動 ===#
self.stream = self.p.open(format=pyaudio.paFloat32,
channels=1,
rate=self.rate,
frames_per_buffer=self.chunk_size,
output=True)
#==== 追加:終了したときの処理 → def quite_app===#
self.master.protocol("WM_DELETE_WINDOW", self.quit_app)
…
def play(self):
self.stream.write(self.sin_t.astype(np.float32).tobytes())
…
def quit_app(self):
self.stream.close()
self.master.destroy()
サイン波の開始値および終了値の見直し
pyaudioのストリームを開きっぱなしにしたころで、ラグは少なくなりましたが、よーく聴いてみるとプツプツと音が途切れています。
特にわかりやすいのが、同じキーを連続して出力したときです。PCのキーを押しっぱなしにすると、キーを連打しているのと同じ状態になりますが、このとき、プツプツ音が途切れていることが分かります。
この原因はsin波の式にありました。
# 元の式
self.t = np.arange(0, self.duration * self.rate) / self.rate
self.sin_t = self.gain * np.sin(2 * np.pi * 0 * self.t)
このとき、pyaudioで書き込まれるデータself.sin_tは毎回、開始値sin(0)、終了値sin(self.duration * self.rate)で生成されますが、データの最後はdurationによってまちまちとなるので、連続で音を鳴らそうとしても波が不連続に結合され、再生するとプツプツ音がするということです。
よって、 波形データの開始値と終了値を定義し、波が連続して結合されるようにする必要があります。
- 引数でデータの開始位置(start_pos)を渡す
- 戻り値としてデータの終わりの位置(end_pos)を戻す
ちなみに参考にした記事はこちらです。
(参考)pythonで波形の生成と再生をリアルタイムに行いたい – Teratail
ここからは実験を交えて確かめてみたいと思います。
まず、一番単純なドの波を(duration*rate)秒生成した場合、これが今の状況です。
次に、2回音が鳴るようにコードを追加し、2回めは開始値と終了値を(duration*rate)秒ずらしました。
結果、滑らかに音が鳴り、作られた2つのグラフを見ても、綺麗に結合できることがわかります。
これを関数で定義したのが下記の部分です。
# 変更前
def start_up(self, gain=0.25, rate=44100, chunk_size=1024):
…
self.t = np.arange(0, duration * self.rate) / self.rate
self.envRange = np.ones(int(duration * self.rate)) # 1字配列
self.sin_t = self.gain * np.sin(2 * np.pi * 0 * self.t)
…
def create_sinwave(self, duration, freq):
…
self.t = np.arange(0, duration * self.rate) / self.rate
self.envRange = np.ones(int(duration * self.rate)) # 1字配列
self.sin_t = self.gain * np.sin(2 * np.pi * freq * self.t)
# 変更後
def start_up(self, gain=0.25, rate=44100, chunk_size=1024):
…
self.start_pos = 0
self.end_pos = self.start_pos + self.duration * self.rate
self.t = np.arange(self.start_pos, self.end_pos) / self.rate
self.envRange = np.ones(int(self.duration * self.rate)) # 1字配列
self.sin_t = self.gain * np.sin(2 * np.pi * 0 * self.t)
def create_sinwave(self, duration, freq):
…
self.start_pos = self.end_pos
self.end_pos = self.start_pos + duration * self.rate
self.t = np.arange(self.start_pos, self.end_pos) / self.rate
self.envRange = np.ones(int(duration * self.rate)) # 1字配列
self.sin_t = self.gain * np.sin(2 * np.pi * freq * self.t)
レシピ②:スレッドを用いてメトロノーム機能をつける
次にメトロノーム機能について考えました。
以前の記事で紹介したとおり、テンポをを知る方法は以下の2通りが考えられました。
- 耳で知る方法
- 目で知る方法
メトロノームのイメージは、テンポに合わせて「カチッカチッ」と音が鳴るものです。
ただ、PC内のスピーカーが1つであることから実装が難しいと考え、目で知る方法に切り替えました。
そして、最もシンプルな方法として、タイマーでの実装を考えました。
具体的には、テンポ(BPM)に応じて、画面上の数字が「1→2→3→4→1→2…」と繰り返されるイメージです。
これを実装するうえで、重大なキーワードがあります。
それは、 ”並行処理(マルチスレッド)”です。
○マルチスレッドの基本
プログラミングの処理は基本的にコードを上から順に実行するものです。
Tkinterではメインループというものを定義し、何かしらのイベントがあるとイベントに応じた処理が実行され、また始めに戻るというものです。
メインループの構造上、キー入力に対する処理(音声を出力してからグラフをプロットする)とタイマーのカウント処理を同時に実行することはできません。
じゃあ、メトロノーム機能つくるの無理じゃん。。。
そう思いましたが、Pythonでこの同時処理の実装を可能にするのが”マルチスレッド”という概念です。
並行処理と並列処理
コンピュータの処理には、並行処理と並列処理という似た言葉があります。
- 並行処理(マルチスレッド):複数の処理を独立に実行できる構成のこと
- 並列処理(マルチプロセッシング):複数の処理を実際に同時に実行すること
ここでは詳細については割愛します。
詳しくは(参考)Pythonの並行処理を理解したい [マルチスレッド編] – Zennを見てみてください。
今回のように、 メインループを実行しながらイベントに応じてタイマーのカウント処理を実行する場合は、並行処理(マルチスレッド)が該当する ようです。
マルチスレッドの概念
『スレッド』というのは CPU(PCの頭脳)が実行する処理を指します。CPUの中には”コア”と呼ばれる処理作業を行う中核が存在し、PCによって1つのCPUに対し複数コアを要しています。
シングルスレッドの場合、CPUの中の1つのコアを用いて処理を実行しています。通常のメインループも1スレッドと考えられますので、複数処理ができません。
マルチスレッドの場合、 コアごとにスレッドを用意することで並行処理が実現できる ようになります。スレッド1でメインループを回し、スレッド2で別のイベント処理を実行する、といったイメージです。
こちらも詳しくは(参考)【Python/tkinter】tkinterでマルチスレッドを利用する – だえう …が分かりやすかったです。
マルチスレッドの実装方法
マルチスレッドの実装には、threadingというpython標準のライブラリが必要になります。
ライブラリをインポートしたあと、スレッドを作成します。このとき、引数 target にはそのスレッドで実行する「関数」や「メソッド」を指定します。そして、スレッドを開始することでスレッドがスタンバイ状態になります。
# ライブラリのインポート
import threading
# スレッドの作成
thread1 = threading.Thread(target=func)
# スレッドの開始
thread1.start()
実際に、スレッドで実行するタイマーを作ってみました。
イメージとしては、メインループ側で今までどおりキープレスイベント時にグラフのプロットや音声出力を行い、ボタンウィジェットを押したタイミングでスレッド側のタイマーがカウント処理を行うといったものです。
スレッドの処理を実行させるためには、メインループとスレッドの間で何かしらのやり取りが必要です。このスレッド間の通知用にstart_flagを用意し、メインループ側でイベントが発生した場合にstart_flagが変化させ、スレッド側の処理を実行するようにしました。
参考サイトを真似てみたところ、スレッドを用いてタイマーが動くことを確認しました。
○結果
以下のコードで、メトロノーム機能をタイマーウィジェットにて実現しました。
ただし、音を鳴らさない状態でと問題なくタイマーが動いていますが、キーを押して音を鳴らすとラグがすごいです。。。
以下、メモです。
threadを用いたタイマーウィジェットは以下の部分です。
まず、__init__()でスレッドを生成し、スタートしています。このスレッドで実行するのはtimer()メソッドです。
def __init__(self, master=None):
…
#==== スレッドの生成と開始 New
self.thread1 = threading.Thread(target=self.timer)
self.thread1.start()
続いて、timerメソッドに通知を送る手段としてstart_flagを設定しています。これがTrueになるときにタイマーのカウント処理が行われます。quitting_flagはスレッド自体を消す際に使います。
def start_up(self, gain=0.25, rate=44100, chunk_size=1024):
…
self.start_flag = False
self.quitting_flag = False
self.count = 0
メインウィンドウには、「スタートボタン」と「ストップボタン」を用意し、それぞれ押されるとstart_flagが変化します。また、カウントされる数字が表示されるテキストボックスも設置しています。
def create_widgets(self):
…
#=== ウィジット:メトロノームウィジェット New
self.metronom_labelFrame = tk.LabelFrame(self.option_frame, text="Metronom", fg='white', bg='#444', relief=tk.FLAT)
self.metronom_labelFrame.grid(column=0, row=2, padx=20)
self.metronom_innerBox = tk.Frame(self.metronom_labelFrame, width=250, height=70)
self.metronom_innerBox.grid(column=0, row=0)
self.metronom_innerBox.grid_propagate(0)
self.metronom_innerBox.grid_anchor(tk.CENTER)
self.start_button = tk.Button(self.metronom_innerBox, text="start")
self.start_button.grid(column=0, row=0)
self.stop_button = tk.Button(self.metronom_innerBox, text="stop")
self.stop_button.grid(column=0, row=1)
self.metronom_label = tk.Label(self.metronom_innerBox, font=("", 20))
self.metronom_label.grid(column=1, row=0, padx=20, rowspan=2)
self.start_button.bind("<ButtonPress>", self.start_button_click)
self.stop_button.bind("<ButtonPress>", self.stop_button_click)
…
#==== 追加 ===#
# スタートボタンが押された時の処理
def start_button_click(self, event):
self.count = 0
self.start_flag = True
#==== 追加 ===#
# ストップボタンが押された時の処理
def stop_button_click(self, event):
self.start_flag = False
timer()では、start_flagがTrueの場合、テンポ(bpm)に応じて数字が「1→2→3→4→1…」とカウントされるようにしています。
#==== 追加 ===#
# タイマー
def timer(self):
while not self.quitting_flag:
if self.start_flag:
self.metronom_label.config(text=self.count)
self.count += 1
if self.count > 4:
self.count = 1
import time
time.sleep(60/self.bpm)
最後に、quit_app()で、アプリを消したときの処理を記述しています。ここで”self.quitting_flag = True”が重要で、これがないとエラーがおこります。
def __init__(self, master=None):
…
#==== 追加:終了したときの処理 → def quite_app
self.master.protocol("WM_DELETE_WINDOW", self.quit_app)
…
#==== 追加 ===#
# 終了ボタンが押された時の処理
def quit_app(self):
self.quitting_flag = True # New
self.stream.close()
self.master.destroy()
…
○課題と解決方法
以下、2つの課題が発生しました。
- RuntimeError: main thread is not in main loop
- メトロノーム実行時、キーを入力するとタイマーカウントにラグが発生する
>RuntimeError: main thread is not in main loop
quit_app()内で”self.quitting_flag = True”を定義しない場合に上記のエラーが発生しました。
これは、アプリを終了した際にスレッド側に終了の通知がいかず、カウントを表示させるメインウィンドウがなくなってしまったため起こるエラーでした。
>メトロノーム実行時、キーを入力するとタイマーカウントにラグが発生する
ラグの原因となっているのは、thread1ではなく、オーディオのstream.write 部分のようです。
なので、メインスレッドをGUI (tkinter + matplotlib の描画部分)のみとし、音声出力もサブスレッドで実施したいと思います。
レシピ③:スレッド、キューを用いてリアルタイム性を調整する
○キューの導入
オーディオ部分もサブスレッドで実行することとしましたが、メトロノームウィジェットと異なる部分があります。
それは、メインスレッド⇔サブスレッドのデータ受け渡しが必要な点です。
ここでいうデータは、波形データ(sin_t)です。この波形データはメインスレッドのグラフプロットにも使いますし、サブスレッドに移そうといている音声出力にも使います。
- メインスレッド:グラフのプロット ⇛ “self.line_wave, = axes_wave.plot(self.sin_t[:500])”
- サブスレッド:音声出力 ⇛ “self.stream.write(self.sin_t.astype(np.float32).tobytes())”
このとき、サブスレッドからメインスレッドで定義されているデータ(sin_t)を直参照するのは安全な操作ではないです。
そこでデータの受け渡しに使うのがQueue(キュー)です。
キューの概要
キューというのはデータ構造の一種です。特徴として、ある入れ物にデータを格納していく際に最初に格納したデータから順に取り出される方式である点が挙げられます(先入れ先出し、FIFOという言い方もします)。
参考までに似たような言葉として、スタック(後入れ先出し、LIFO)というのがあります。
今回やろうとしているメインスレッド⇔サブスレッドの波形データ受け渡しをイメージします。まず、キューという箱をイメージします。メインスレッドでキーが入力されるたびに波形データ(sin_t)をキューに入れていきます。次にサブスレッドでキューから波形データを取り出します。このとき、キューは先入れ先出しのため、連続でキーを入力してもきちんと先に入れた波形データが取り出され音声として再生されます。
キューの実装方法
キューの実装にはqueueというpython標準ライブラリを使います。
キューの構文はとてもシンプルで、キューを箱と考え、q.put()で要素を追加したりq.get()で要素を取り出したりして使います。
(参考)Python入門 (3) -マルチスレッド|npaka|note
# ライブラリのインポート
import queue
# キューの準備
q = queue.Queue()
# キューに要素を追加
q.put()
# キューから要素を取得
q.get()
○結果
以下のコードで、グラフプロットと音声出力、メトロノーム間のラグがなくなりました。
ステップ1.キーボード入力で音を鳴らすは完成でよいのではないでしょうか!!!
以下、メモです。
play()メソッド実行用に新たにスレッドを作っています。theread2とのやり取りにはplay_flagを用意しました。
また、今回新たに使用するキューを定義しています。
def __init__(self, master=None):
#=== スレッドの生成と開始 New
…
self.thread2 = threading.Thread(target=self.play, daemon = True)
self.thread2.start()
self.q = queue.Queue() # 同期FIFOキューの作成
…
#=== 初期値
def start_up(self, gain=0.25, rate=44100, chunk_size=1024):
…
#=== スレッドの初期値
…
self.play_flag = False # playスレッドの同期用
キーを入力するたびにplay_flagがTrueになり、play()メソッドが実行されます。
def press_key(self, event):
…
if self.key == 'a':
freq = FREQ_SCALE['ド/C4']
self.create_sinwave(self.duration, freq) # def create_sinwave()に渡す
self.play_flag = True
elif self.key == 's':
freq = FREQ_SCALE['レ/D4']
self.create_sinwave(self.duration, freq) # def create_sinwave()に渡す
self.play_flag = True
…
キーを押したタイミングで、キューが空の場合、self.sin_tがキューに追加されます。そして、play()メソッドで新たにself.itemという名前でキュー内の波形データを取得し、実行します。
# sin波の作成
def create_sinwave(self, duration, freq):
…
self.sin_t = self.gain * np.sin(2 * np.pi * freq * self.t)
self.q.put(self.sin_t, block=True, timeout=None) # New
def press_key(self, event):
global freq # 初期値として、create_sinwave(FREQ_SCALE['ド/C4'])のために必要
if self.q.empty():
…
def play(self):
while not self.quitting_flag:
if self.play_flag:
self.item = self.q.get(block=True, timeout=None) # New
self.stream.write(self.item.astype(np.float32).tobytes()) # New
if self.quitting_flag == True:
break
まとめ&次回予告
○まとめ
今回は前回に引き続き画面(GUI)をカスタマイズしてみました。結果、BPMをスケールバーで変更できたり、押したキーを表示したりとウィジェットが充実してきました。(レシピ②)。
使用したライブラリ(レシピ②)
- numpy
- PyAudio
- Tkinter
- matplotlib
○今日のエラーと解決方法
エラー
- エラーではありませんが、辞書DURATIONをclass Synth_applicationの外に記述した結果、class内のbpmの値を変更しても、bpmを変数に持つDURATIONの値が変わりませんでした。
解決方法
- class内のreset_bpm()メソッドに辞書を配置することで、スケールバーにてbpmが変更された際にDURATIONの値も変化するようになりました。
○次回予告
あと欲しい機能としてはメトロノーム機能です。また、音声出力とグラフのプロット部分で、グラフが変化してから音が鳴る形でラグが生じているため、修正していきたいと思います。
- キーボード入力で音を鳴らす
☑1−1.1つの音を鳴らす
☑1-2.音階を鳴らす
☑1-3.音の長さを自由に変える
☑1-4. 画面をつくる
1-5.メトロノーム機能をつくる
lteru
最新記事 by lteru (全て見る)
- 【ノーコード・ローコード】OutSystemsでアプリづくり#04 ~マッチングアプリをつくる①~ - 2022年8月13日
- 【ノーコード・ローコード】OutSystemsでアプリづくり#03 ~簡単なリスト型アプリをつくる③~ - 2022年7月24日
- 【ノーコード・ローコード】OutSystemsでアプリづくり#02 ~簡単なリスト型アプリをつくる②~ - 2022年7月22日