新卒FWエンジニアのnaritakuです。
この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 10 日目の記事です。
弊社製品Akeurnコントローラーはブザーを内蔵しているのですが、新規FWの評価中にbeep音を出して再起動をする場面に遭遇しました。
ソースコードからではなく、評価時の動作からデバッグすることもあるのですが、この記事では新年の寅年にあやかり、たまたま録音していた当時の音をスペクトラムに変換し、再起動直前になっていたbeep音の周波数の特定をします。
目次
解析する環境の準備
iPhoneのボイスメモで録音したところ.m4a拡張子で、1チャンネルの音源が記録されていました。
普段の組み込み開発はc言語やjavascriptなどを使いますが、簡単に解析することを目標に、本記事ではpythonで実施します。 google colabratoryを使うとセットアップもほとんどせずに解析できます
colabratory内ではGoogleDriveにアップロードした音源の利用やpipでのライブラリのインストールもできます。
from google.colab import drive drive.mount('/content/drive') !pip install pydub
スペクトラムの表示
scipyのsignal.spectrogram()を使ってスペクトラムに変換します。
窓関数はピークの周波数を精度よく出したいため、ハミング関数を使います。
サンプリング数Nが2のべき乗であることで高速にフーリエ変換できます。 今回はN=1024でのFFTをSTEP(=100)データずつずらしながら実施します。
from pydub import AudioSegment from scipy import signal import numpy as np import matplotlib.pyplot as plt from matplotlib.colors import Normalize def loadAudio(filePath): audio = AudioSegment.from_file(filePath, "m4a") print("チャンネル数",audio.channels) print("サンプリング周波数",audio.frame_rate,"[Hz]") print("再生時間",audio.duration_seconds,"[s]") #1chのみの音源を取り出す samples = np.array(audio.get_array_of_samples()) sample = samples[::audio.channels] return sample,audio.frame_rate sound,frameRate = loadAudio("/content/drive/MyDrive/CTL.m4a") N=1024 step=100 freqs, times, Sx = signal.spectrogram(sound, fs=frameRate, window='hamming',nperseg=N, noverlap=N-step,detrend=False, scaling='spectrum') # スペクトログラム変数 plt.figure() f,ax = plt.subplots(figsize=(12,8)) im=ax.pcolormesh(times, freqs/1000, 10* np.log10(Sx), cmap='magma',norm=Normalize(vmin=-60, vmax=60)) ax.set_yscale('log') ax.set_ylim(0.1,24) ax.set_ylabel('Frequency[kHz]') ax.set_xlabel('Time[s]') pp=plt.colorbar(im) plt.show()
チャンネル数 1 サンプリング周波数 48000 [Hz] 再生時間 4.378729166666667 [s]
全体的に低周波の音が強くなっていますが、beep音の出ている3.0[s]近辺では特定の周波数の音が大きくなっていることが確認できます。
beep音のみのデータにトリミングして再度FFTを行い、上記の特定の周波数について求めていきます
beep音のみの解析
beep音が再生されている時間のみにトリミングしたデータを使います。
Beep音を矩形窓とハミング窓を使って切り取りフーリエ変換します。
from pydub import AudioSegment from scipy import signal import numpy as np import matplotlib.pyplot as plt def loadAudio(filePath): audio = AudioSegment.from_file(filePath, "m4a") print("チャンネル数",audio.channels) print("サンプリング周波数",audio.frame_rate,"[Hz]") print("再生時間",audio.duration_seconds,"[s]") #1chのみの音源を取り出す samples = np.array(audio.get_array_of_samples()) sample = samples[::audio.channels] return sample,audio.frame_rate def fft(sample,frameRate): # 音声全体を1つの波形としてフーリエ変換 sp = np.fft.fft(sample) f = np.fft.fftfreq(sample.shape[0], 1.0/frameRate) # 正の周波数部分だけにする f= f[:f.shape[0]//2] sp = sp[:sp.shape[0]//2] sp[0] = sp[0] / 2 magnitude=np.abs(sp) return f,magnitude # フーリエ変換する波形の表示 sample,frameRate=loadAudio("/content/drive/MyDrive/beep.m4a") N=len(sample) time=[i/frameRate for i in range(N)] hamming=np.hamming(N) * sample plt.figure(figsize=(16,16)) plt.subplot(2,1,1) plt.plot(time,sample,label='Rectangular window') plt.plot(time,hamming,label='Hamming window') plt.xlabel('time [s]') plt.ylabel('amplitude') plt.legend() # フーリエ変換とピークとなる周波数の表示 f,magnitude=fft(sample,frameRate) f_h,magnitude_h=fft(hamming,frameRate) maxi = signal.argrelmax(magnitude, order=100) maxi_h = signal.argrelmax(magnitude_h, order=100) plt.subplot(2,1,2) plt.plot(f, magnitude) plt.plot(f_h, magnitude_h) plt.plot(f[maxi[0]],magnitude[maxi[0]],'ro',label='peak(origin)') plt.plot(f_h[maxi_h[0]],magnitude_h[maxi_h[0]],'gx',label='peak(hamming)') plt.yscale("log") plt.xlabel('frequency [Hz]') plt.ylabel('magnitude') plt.legend()
チャンネル数 1 サンプリング周波数 48000 [Hz] 再生時間 0.18695833333333334 [s]
極大値かつ前後100点の中で最大値を取るものについて 短形窓での極大値を赤丸、ハミング窓での極大値を緑Xでプロットすると、0Hzから16000[Hz]まではどちらも同じ周波数でピークが出ており、ピークとなる周波数ががほぼ等間隔になりました。 1100[Hz]あたりの音がブザーで鳴らしている周波数、それ以降のピークはブザーの倍音によるものである仮定のもと、ブザーの出力している周波数を推定します。
最小二乗法で周波数を推定する
求めたい周波数aのx倍の倍音の周波数がyとなる関数 y=ax についてaの値を最小二乗法で求めます。 サンプルとして使う点は(x,y)=(0,0)と上記のグラフの赤丸でプロットされた2~13番目のピークの値とします。 (x,y)=(0,0)を使うのは0倍の時に0 [Hz]であることを期待しているためです。
# FFTの極大値から周波数を推定する y=np.append(0, f[maxi[0][1:14]]) x=np.arange(len(y)) # y=mxとして最小二乗法 A = np.vstack([x, np.zeros(len(x))]).T m, _ = np.linalg.lstsq(A, y, rcond=None)[0] plt.plot(x,y,'gx',label='peaks of FFT(Rectangular window)') plt.plot(x,m*x,label='Approximate line by least squares method') plt.plot(1,m,'ro',label='Estimated frequency') plt.xlabel('scale of tone') plt.ylabel('frequency [Hz]') plt.legend() print("最小二乗法で推定した周波数", m,"[Hz]")
最小二乗法で推定した周波数 1187.9528242354183 [Hz]
グラフを見ても綺麗に倍音でピークになっていそうです。 推定できた周波数は約1187[Hz]でしたが、Akerunコントローラーが出力しようとしていた音階を推測します。
どの程度正しい周波数なのか
かなりいい加減な解析をしているので、ピッタリ1187[Hz]の音源ではないはずです。 現時点でどの程度信用できる周波数だったのか考えておきます。
ピークとして使った周波数の精度
今回のデータでは8974点のフーリエ変換を行なっていますが、
サンプリング周波数48000[Hz]であるために、[Hz]の波に変換しています。
今回はピークとして使った周波数も約5.34[Hz]ごとに離散化されているため、本来の再生される周波数とたまたまサンプリングに使われる周波数が合致しなければ本来の周波数との誤差が生まれてしまいます。
申し訳程度に最小二乗法でこの誤差が小さくなることを期待していますが、ピークとして使った周波数にすら信用できる区間が約5.34[Hz]の幅があることを念頭に入れておきましょう。
音階とその周波数
12平均律では $2^{\sqrt{12}}$ 倍の周波数で次の音階が得られるため、 440[Hz] を ラ/A4 としたとき
- ド♯/C♯6の周波数は約1109[Hz]
- レ/D6の周波数は約1175[Hz]
- レ♯/D♯6の周波数は約1245[Hz]
となります。
今回推定したピークとは10Hzほど低いものの、人間の耳ではbeep音はD6の音として聞こえることが想定され、当時slackで即レスをくれた音楽の得意なエンジニアと同じ結論になりました。これらをもとに、デバッグすることができそうですね。
参考
株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co
Akerun Proの購入はこちらから akerun.com