kivyはモバイルにも対応したGUIアプリ開発ライブラリです。今回はモバイルアプリを意識して別ページで用意した設定画面をスワイプで呼び出せる録音アプリの作り方を紹介します。録音はPyAudio、波形表示はmatplotlibと連携しています。
こんにちは。wat(@watlablog)です。今回はkivyによるモバイル用録音アプリのPythonコード例を紹介します!
この記事の目標と前提知識
目標:動画で完成イメージを確認する
アプリの仕様を色々説明する前に、完成イメージを動画で確認してみましょう。以下のYouTube動画に本アプリをデスクトップ環境で動作させた結果を示します。
詳細は記事の後半で解説しますが、「REC」ボタンをクリックしてマイクによる録音を行うだけのアプリです。
測定時間やフレームサイズといった計測に関係する設定値をスワイプ画面で変更可能としているところにモバイルっぽさを出してみました。
モバイル環境での動作確認はまだですが、設定と録音イベントというシンプルなGUI構成を学ぶことをこの記事の目標とします。
前提知識
kivyの基本コーディングスタイル
kivyはPythonコードとkv言語によるコードを組み合わせてGUIアプリを開発します。このページでは基本の書き方の説明をしないので、インストールを含む基礎は以下の記事を参照してください。
Python/kivyでGUIアプリ開発!基本の書き方を学ぶ
matplotlibとの連携方法
録音したデータはmatplotlibを使ってプロットします。kivyでmatplotlibを埋め込む方法は少しやっかいな要素があります。matplotlibをkivy上で使うためには、以下の記事の内容を把握しておいてください。
kivyでmatplotlibを使う時にハマったので解決の備忘録
PyAudioによる録音方法
クロスプラットフォームの録音ライブラリとして、PythonではPyAudioが有名です。WATLABブログでも録音関係のコードはPyAudioで書いていました。
PyAudioはPythonのバージョンやOSによってはインストール時にエラー発生する可能性があります。「Python3.7でPyAudioがインストールできない時の解決法」の記事ではその辺の説明をしています。
録音に関する基本は「PythonのPyAudioで音声録音する簡単な方法」、また今回のアプリでマイクチャンネルを自動取得する方法は「Python/PyAudioでマイクのチャンネルを確認する方法!」に記載しました。
classの知識
GUIアプリを作る時はclassを使って効率よく記述することがほぼ不可欠です。
classとは設計図のようなもので、実際に使うためにはインスタンス化(実体化)させる必要がありました。
Pythonにおけるclassの考え方、初期化時に実行されるコンストラクタやメソッドの考え方は「Pythonのクラスの使い方とオブジェクト指向の考え方を理解する」にまとめましたので、こちらも前提知識としています。
また、WATLABブログでは過去に信号処理アプリをwxPythonというGUIアプリ開発ライブラリで作ってみました。
この時はGUIに関する機能(ボタンのクリックやファイルの取り扱い)、信号処理に関する機能…と機能毎にファイルを分けていました。今回もその考え方を踏襲してプログラミングをします。詳しくは以下の記事を参考にしてください。
アプリ完成編:wxPythonで信号処理のGUIアプリをつくろう⑥
しかしこの考え方はIT初心者の筆者が我流でやっているだけなので、もしかしたらもっとスマートな方法があるかも知れません。その点はご了承ください。
kivyとPyAudioで作る録音アプリのPythonコード
動作環境
この記事のコードは以下のPC環境とPython環境で動作を確認しました。今回はデスクトップ環境における動作確認のみとなります。
Mac | OS | macOS Catalina 10.15.7 |
---|---|---|
CPU | 1.4[GHz] | |
メモリ | 8[GB] |
Python | Python 3.9.6 |
---|---|
PyCharm (IDE) | PyCharm CE 2020.1 |
Numpy | 1.21.1 |
Scipy | 1.4.1 |
Pandas | 1.0.5 |
matplotlib | 3.4.3 |
PySoundFile | 0.9.0.post1 |
kivy | 2.1.0 |
Kivy-Garden | 0.1.5 |
コード
my.kv
ボタンやラベルといったウィジェットを記述するkvファイルを以下に示します。ここではPageLayoutを使ってスワイプによる画面遷移を設定しています。kvファイルの名称は「my.kv」です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
#:kivy 2.1.0 <Root>: PageLayout: BoxLayout: id:viewer orientation:'vertical' size:root.width, root.height Button: id:record_button text:'REC' size_hint_y:0.2 on_release: self.state = 'down' root.on_button() self.state = 'normal' BoxLayout: orientation:'vertical' size:root.width, root.height canvas.before: Color: rgba:0, 0, 0, 0.9 Rectangle: pos:self.pos size:self.size BoxLayout: orientation:'horizontal' size_hint_y:0.25 Label: text:'Record time[s]' font_size:36 TextInput: id:record_time text:'1' font_size:50 halign: 'center' input_filter:'float' padding_y:[50,0] BoxLayout: orientation:'horizontal' size_hint_y:0.25 Label: text:'Frame size' font_size:36 TextInput: id:frame_size text:'1024' font_size:50 halign: 'center' input_filter:'int' padding_y:[50,0] BoxLayout: orientation:'horizontal' size_hint_y:0.25 Label: text:'' TextInput: text:'' BoxLayout: orientation:'horizontal' size_hint_y:0.25 Label: text:'' TextInput: text:'' |
スワイプして持ってくる2ページ目はBoxLayoutにLabelとTextInputを設置しています。設定画面をスワイプで持ってくると、背景色が設定されていないウィジェット(ここではLabel)の背景に1ページ目の画面が映り込みます。
これを防ぐためにcanvas.before:を書いて背景を四角形で作成しました(アルファチャンネルを0.9にすることで少し透明にしています)。
TextInputにはy方向にPaddingを入れていますが、これはデフォルトだとテキストが一番上にはりついて見えるため、少し下げる意味で設定しました(valignでは中央揃えができない)。
Buttonのイベント(on_release)やTextInputの値参照の兼ね合いから、今回はRootクラスのみでGUIを表現しました(idsで検索しやすくしている)。
dspToolkit.py
dspToolkit.pyは信号処理関係の機能をclassにまとめたPythonファイルです。「アプリ完成編:wxPythonで信号処理のGUIアプリをつくろう⑥」で作成したものに録音機能のメソッド(record()、get_mic_index())を追加してあります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
import soundfile as sf import numpy as np import pandas as pd from scipy import fftpack from scipy import signal import pyaudio class DspToolkit(): def __init__(self): '''コンストラクタ ''' # 時間波形関係の変数 self.time_y = [] self.time_y_original = [] self.time_x = [] self.sampling = 44100 self.dt = 0 self.length = 0 self.record_time = 0 # フーリエ変換で使う変数 self.frame_size = 1024 self.overlap = 0 self.dbref = 2e-5 self.averaging = 0 self.fft_axis = [] self.fft_mean = [] self.fft_array = [] self.acf = 0 # フィルタ処理で使う変数 self.lp_freq = 1000 self.hp_freq = 1000 self.bp_freq_low = 500 self.bp_freq_high = 1000 self.bs_freq_low = 500 self.bs_freq_high = 1000 self.attenuation_pass = 3 self.attenuation_stop = 40 def open_wav(self, path): ''' パスを受け取ってwavファイルを読み込む ''' self.time_y, self.sampling = sf.read(path) self.time_y_original = self.time_y.copy() self.get_time_information() def open_csv(self, path): ''' パスを受け取ってcsvファイルを読み込む ''' df = pd.read_csv(path, encoding='SHIFT-JIS') self.time_y = df.T.iloc[1] self.time_y_original = self.time_y.copy() self.sampling = 1 / df.T.iloc[0, 1] self.get_time_information() def get_time_information(self): ''' Time plotの表示器に表示させる情報の計算と時間軸作成を行う ''' self.dt = 1 / self.sampling self.time_x = np.arange(0, len(self.time_y), 1) * self.dt self.length = len(self.time_x) * self.dt print('Time waveform information was obtained.') def calc_overlap(self): ''' 時間波形をオーバーラップ率で切り出してリスト化する ''' frame_cycle = self.frame_size / self.sampling x_ol = self.frame_size * (1 - (self.overlap / 100)) self.averaging = int((self.length - (frame_cycle * (self.overlap / 100))) / (frame_cycle * (1 - (self.overlap / 100)))) time_array = [] final_time = 0 if self.averaging != 0: for i in range(self.averaging): ps = int(x_ol * i) time_array.append(self.time_y[ps:ps+self.frame_size:1]) final_time = (ps + self.frame_size) / self.sampling print('Frame size=', self.frame_size) print('Frame cycle=', frame_cycle) print('averaging=', self.averaging) return time_array, final_time return time_array, final_time def hanning(self, time_array): ''' ハニング窓をかけ振幅補正係数ACFを計算する ''' han = signal.hann(self.frame_size) self.acf = 1 / (sum(han) / self.frame_size) # オーバーラップされた複数時間波形全てに窓関数をかける for i in range(self.averaging): time_array[i] = time_array[i] * han return time_array def fft(self, time_array): ''' 平均化フーリエ変換をする ''' fft_array = [] for i in range(self.averaging): # FFTをして配列に追加、窓関数補正値をかけ、(Fs/2)の正規化を実施。 fft_array.append(self.acf * np.abs(fftpack.fft(np.array(time_array[i])) / (self.frame_size / 2))) # 全てのFFT波形のパワー平均を計算してから振幅値とする。 self.fft_axis = np.linspace(0, self.sampling, self.frame_size) self.fft_array = np.array(fft_array) self.fft_mean = np.sqrt(np.mean(self.fft_array ** 2, axis=0)) def db(self, x, dBref): ''' dB変換をする ''' y = 20 * np.log10(x / dBref) return y def filter(self, filter_type): ''' フィルタをかける ''' # ナイキスト周波数fnを設定して通過域端周波数wpと阻止域端周波数wsを正規化 # 阻止域周波数は通過域周波数の2倍にしている仕様(ここは目的に応じて変えても良い) fn = self.sampling / 2 # Lowpassフィルタ if filter_type == 'low': wp = self.lp_freq / fn ws = (self.lp_freq * 2) / fn try: # フィルタ次数とバタワース正規化周波数を計算 N, Wn = signal.buttord(wp, ws, self.attenuation_pass, self.attenuation_stop) # フィルタ伝達関数の分子と分母を計算 b, a = signal.butter(N, Wn, filter_type) self.time_y = signal.filtfilt(b, a, self.time_y) except ValueError: return -1 # Highpassフィルタ if filter_type == 'high': wp = self.hp_freq / fn ws = (self.hp_freq * 2) / fn try: # フィルタ次数とバタワース正規化周波数を計算 N, Wn = signal.buttord(wp, ws, self.attenuation_pass, self.attenuation_stop) # フィルタ伝達関数の分子と分母を計算 b, a = signal.butter(N, Wn, filter_type) self.time_y = signal.filtfilt(b, a, self.time_y) except ValueError: return -1 # bandpassフィルタ if filter_type == 'band': fp = np.array([float(self.bp_freq_low), float(self.bp_freq_high)]) fs = np.array([float(self.bp_freq_low)/2, float(self.bp_freq_high)*2]) wp = fp / fn ws = fs / fn try: # フィルタ次数とバタワース正規化周波数を計算 N, Wn = signal.buttord(wp, ws, self.attenuation_pass, self.attenuation_stop) # フィルタ伝達関数の分子と分母を計算 b, a = signal.butter(N, Wn, filter_type) self.time_y = signal.filtfilt(b, a, self.time_y) except ValueError: return -1 # bandstopフィルタ if filter_type == 'bandstop': fp = np.array([float(self.bs_freq_low), float(self.bs_freq_high)]) fs = np.array([float(self.bs_freq_low) / 2, float(self.bs_freq_high) * 2]) wp = fp / fn ws = fs / fn try: # フィルタ次数とバタワース正規化周波数を計算 N, Wn = signal.buttord(wp, ws, self.attenuation_pass, self.attenuation_stop) # フィルタ伝達関数の分子と分母を計算 b, a = signal.butter(N, Wn, filter_type) self.time_y = signal.filtfilt(b, a, self.time_y) except ValueError: return -1 return 0 def get_mic_index(self): ''' マイクチャンネルのindexをリストで取得する ''' # 最大入力チャンネル数が0でない項目をマイクチャンネルとしてリストに追加 pa = pyaudio.PyAudio() mic_list = [] for i in range(pa.get_device_count()): num_of_input_ch = pa.get_device_info_by_index(i)['maxInputChannels'] if num_of_input_ch != 0: mic_list.append(pa.get_device_info_by_index(i)['index']) return mic_list def record(self, index, samplerate, fs, time): ''' 録音する関数 ''' pa = pyaudio.PyAudio() # ストリームの開始 data = [] dt = 1 / samplerate stream = pa.open(format=pyaudio.paInt16, channels=1, rate=samplerate, input=True, input_device_index=index, frames_per_buffer=fs) # フレームサイズ毎に音声を録音していくループ for i in range(int(((time / dt) / fs))): frame = stream.read(fs) data.append(frame) # ストリームの終了 stream.stop_stream() stream.close() pa.terminate() # データをまとめる処理 data = b"".join(data) # データをNumpy配列に変換/時間軸を作成 data = np.frombuffer(data, dtype="int16") / float((np.power(2, 16) / 2) - 1) t = np.arange(0, fs * (i + 1) * (1 / samplerate), 1 / samplerate) return data, t |
使っていないメソッドも一緒に記載していますが、今後のためにそのままにしています。
過去に作ったclassファイルがそのまま使えることに感動!これがオブジェクト指向の強みでしょうか。
main.py
最後はメインの処理を記述するPythonファイルです。このファイルを実行することでプログラムが動作します。
過去記事ではmatplotlibを連携させるために、PlotWidgetとRootの2つのclassを使っていました。しかし、それだとそれぞれのクラス間でデータのやり取りを行うのが難しかったので、今回はRootのみを使って主要のイベント処理を行う構成にしました。
Rootの中でDspToolkitクラスをインスタンス化し、self.dsp…を使ってDspToolkitクラス内のプロパティ値を更新していくやり方が個人的には腑に落ちたからです。
クラスが2つ以上あると共通の実体を使うことができないため、データのやり取りに苦労しました(グローバル変数は使いたくない…)。
TextInputに入力させていた計測時間とフレームサイズ(1回の録音チャンクで取得するデータポイント数)を読み込んで録音しますが、無効な値とならないようにon_buttonメソッドのif文で制限をかけました(計測時間はとりあえず60を設定。長すぎるとメモリやストレージを圧迫するため)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
from kivy.app import App from kivy.uix.boxlayout import BoxLayout from kivy.garden.matplotlib.backend_kivyagg import FigureCanvasKivyAgg from kivy.core.window import Window from matplotlib import pyplot as plt from dspToolkit import DspToolkit class Root(BoxLayout): ''' Pythonで計算をした結果をmatplotlibでプロットするクラス ''' def __init__(self, **kwargs): super().__init__(**kwargs) ''' コンストラクタ:プロットの初期化とDspToolkitのインスタンス化を行う''' # プロットの初期化 x = [] y = [] self.fig = self.plot(x, y) # ウィジェットとしてfigをラップする self.ids.viewer.add_widget(FigureCanvasKivyAgg(self.fig)) # DspToolkitをインスタンス化する self.dsp = DspToolkit() def on_button(self): ''' ボタンがクリックされた時のイベント:波形を更新する ''' # TextInputからの入力値を引用する(kvのTextInputでfilterしているので数値変換は保証されている) self.dsp.record_time = float(self.ids.record_time.text) self.dsp.frame_size = int(self.ids.frame_size.text) # 測定時間が指定範囲外であれば測定をしない min_time = 0 max_time = 60 if self.dsp.record_time > min_time and self.dsp.record_time <= max_time: pass else: print('Error:Record time is out of range!') return # フレームサイズ範囲外であれば測定をしない min_frame_size = 0 max_frame_size = 10000 if self.dsp.frame_size > min_frame_size and self.dsp.frame_size <= max_frame_size: pass else: print('Error:Frame size is out of range!') return # マイクチャンネルを自動で取得(最初のマイクを使用する) mic_ch = self.dsp.get_mic_index()[0] # 録音する print('Record time[s]=', self.dsp.record_time) print('Sampling rate[Hz]=', self.dsp.sampling) print('Frame size=', self.dsp.frame_size) data, t = self.dsp.record(mic_ch, self.dsp.sampling, self.dsp.frame_size, self.dsp.record_time) # figの再作成 plt.close() self.fig = self.plot(t, data) # 値の更新(ウィジェットを削除して新しく追加している。ボタンも一緒に消して再度addしている。なんかもっと良い方法が??) temp_button = self.children[0].children[1].children[1] self.children[0].children[1].clear_widgets() self.ids.viewer.add_widget(temp_button) self.ids.viewer.add_widget(FigureCanvasKivyAgg(self.fig)) return def plot(self, x, y): ''' プロットする共通のメソッド ''' # フォントの種類とサイズを設定する。 plt.rcParams['font.size'] = 20 plt.rcParams['font.family'] = 'Times New Roman' # 目盛を内側にする。 plt.rcParams['xtick.direction'] = 'in' plt.rcParams['ytick.direction'] = 'in' # Subplot設定とグラフの上下左右に目盛線を付ける。 self.fig = plt.figure() self.ax1 = self.fig.add_subplot(111) self.ax1.yaxis.set_ticks_position('both') self.ax1.xaxis.set_ticks_position('both') # 軸のラベルを設定する。 self.ax1.set_xlabel('Time[s]') self.ax1.set_ylabel('Amplitude') # スケールを設定する。 #self.ax1.set_xlim(0, 10) #self.ax1.set_ylim(-1, 1) # プロット self.ax1.plot(x, y, lw=1) # レイアウト設定 self.fig.tight_layout() return self.fig class MyApp(App): title = 'WATLAB Recorder' # デスクトップ環境上でモバイル画面の比率を検証するためにサイズ設定をしている(正式には外す) ratio = 16 / 9 w = 400 Window.size = (w, w * ratio) def build(self): return Root() if __name__ == '__main__': MyApp().run() |
実行結果
プログラムを実行すると、冒頭の動画で示したとおりの結果を得ます。
GIF動画も参考に載せておきます。
まとめ
kivyとmatplotlib、PyAudioを連携させてスワイプ機能付きの録音アプリを作ってみました。
wavファイルへの保存や周波数分析といった機能追加は既に過去記事で行っているので、あとはそれほど難しくないでしょう。
今回はできるだけシンプルな構成にまとめたかったのでここで終わりですが、録音結果の周波数分析機能追加等は別記事にしようと思います。
最終的にはiOSアプリまでできると良いのですが、それはまだ未知なので今後に期待。
こんなに短時間で録音アプリができるとは思いませんでした!
Twitterでも関連情報をつぶやいているので、wat(@watlablog)のフォローお待ちしています!