🎼

手っ取り早く音声対話AIを作りたい人向けの音声処理講座①

2024/08/24に公開

LLMで音声でやり取りするAIを作りたいなと思いましたが、今まで音声について触れてきませんでした。

今回実装するにあたり、Qiitaやzennの記事では、試してみたレベルはできても実装には程遠いと感じました。

細かい理屈とか蘊蓄なぞいらねーから、結局どうやって実装すんねん、という短気なエンジニア向けです。

ライブラリ

numpy、matplotlib

  • 言わずと知れた計算やグラフ化するライブラリ

fft、 ifft

  • フーリエ変換するためのライブラリ
  • scipyやnumpyの機能として提供されている
  • 後で述べるように、音声を周波数にバラすときやスペクトル減算するときにフーリエ変換を使う

pydub

  • 音楽データ加工のためのライブラリ
  • pydub自体は音量を上げるとかトリミングするといった基本的な機能しかない
  • 特定の周波数だけをカットするといった作業には、いったんデータをnumpyで数値化し、数値処理してから音楽データに直すという作業をする

faster-whisper

  • whisperというopenAI社が開発した音声認識ライブラリの改良版
  • その名の通りwhisperと比較して圧倒的に処理速度が早く、人間とAIが音声で対話できることを目的として作られている

前知識と準備

そもそも「音」とは波であり、生の音声データというのは様々な周波数の音が混じり合ったものです。

この様々な周波数が混ざった音のデータから、音声の特徴をとらえるために必要な値だけを抽出・強調して、音声認識AIが何と喋っているのか判別しやすくする、というのが音声データの加工の主旨となります。

音声そのものはアナログですが、PCまたはスマホ等で録音しますと、デジタルな数値の集まりになります。
このデジタルな数値をあれこれ弄っていくのが、音声データの加工です。
なお、数値データをあれこれ弄るにはpythonのnumpyを用います。

numpyについて語り出すと本筋を逸れてしまいますので、まあ実装しながらこんなことやってんねんなとイメージする程度でいいでしょう

時間と振幅を見てみよう

音声データを加工するにあたって、音声がどのような特徴をしているのか把握する必要があります。

まずはマイクチェックも兼ねて、時間と振幅をリアルタイムに図として見ることにしましょう。
(そもそも音声がきちんと取れていないのにノイズ除去などやろうとしても無意味ですからね)

サンプルコードに書いたように、matplotlibを使って描画します。

サンプルコード
import numpy as np
import sounddevice as sd
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

# サンプリングレート
fs = 44100
# 表示するフレームの数
frame_size = 1024
# グラフを更新する間隔(ミリ秒)
interval = 50

# プロットの設定
fig, ax = plt.subplots()
x = np.arange(0, frame_size)
line, = ax.plot(x, np.random.rand(frame_size))

ax.set_ylim(-2048, 2048)
ax.set_xlim(0, frame_size)

def update_plot(frame):
    # マイクからの音声データを取得
    audio_data = sd.rec(frame_size, samplerate=fs, channels=1, dtype='int16')
    sd.wait()
    # グラフを更新
    line.set_ydata(audio_data.flatten())
    return line,

# アニメーションを開始
ani = FuncAnimation(fig, update_plot, interval=interval)

plt.show()

プログラムを実行した状態で、Macに向かって話しかけてみましょう。
以下のようなグラフが出るはずです。

ここで振幅とは音の大きさを示しています。

冒頭で述べたように音声というものは様々な周波数の音の集まりですが、その音ひとつひとつは常に一定ではなく時間によって刻々と変化しています。

ですが、この図の縦軸は色々な周波数をごっちゃにしているので、これだけだと解析には使えません。

周波数の分布を見てみよう

そこで、ある時間における周波数を分解して、低い周波数から高い周波数の音がどれだけ含まれているか分布を見ることにします。

このバラす作業をフーリエ変換と言いまして、複雑な計算式(アルゴリズム)を経て算出されるのですが、ライブラリが良しなにやってくれるので計算式は知らなくても良いです。

マイクが拾っている音にはどんな周波数の音が含まれているか可視化してみましょう。

サンプルコード
import numpy as np
import sounddevice as sd
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

# サンプリングレート
fs = 44100
# 表示するフレームのサイズ
frame_size = 1024
# 更新間隔(ミリ秒)
interval = 50

# プロットの設定
fig, ax = plt.subplots()
x = np.fft.fftfreq(frame_size, 1 / fs)
line, = ax.plot(x[:frame_size // 2], np.random.rand(frame_size // 2))

ax.set_ylim(0, 20000)
ax.set_xlim(0, 20000)
ax.set_xlabel('Frequency (Hz)')
ax.set_ylabel('Amplitude')

def update_plot(frame):
    # マイクからの音声データを取得
    audio_data = sd.rec(frame_size, samplerate=fs, channels=1, dtype='int16')
    sd.wait()
    # フーリエ変換
    fft_data = np.fft.fft(audio_data.flatten())
    fft_magnitude = np.abs(fft_data)
    # グラフを更新
    line.set_ydata(fft_magnitude[:frame_size // 2])
    return line,

# アニメーションを開始
ani = FuncAnimation(fig, update_plot, interval=interval)

plt.show()

何かしら声を発すると、周波数3000以下の値が増加することがわかります。

音声データを準備

雑音が入っているほうが処理の効果を実感しやすいので、BGMが流れているカフェや少しうるさい環境でマイクに向かって話して録音しましょう。

MacやiPhoneだとボイスメモが使えます。
録音したデータはmp4なので、無料のオンライン変換サービスなどを使ってmp3に変換しておきましょう

音声データを加工してみよう

音声加工にはpydubというライブラリにあるAudioSegmentクラスを使います。

冒頭で述べた通り、音というのは様々な周波数の音成分の塊であり、AudioSegmentというのはこれらの音成分を「切片」としてデータ加工しやすくしたものと解釈しています。

以下のようにmp3形式の音声ファイルを読み込むことができます。

from pydub import AudioSegment

audio:AudioSegment = AudioSegment.from_mp3("自声atドトール.mp3")

チャンネル

ステレオ音声だとチャンネルは2、モノラルなら1です。

音声のテキスト変換においてはリアルさは必要ありませんので、以下のようにモノラル変換しておきましょう。

# 音声をモノラルに変換
audio = audio.set_channels(1)

ローパスフィルターとハイパスフィルター

人間の声の周波数は300Hzから3400Hzの範囲内と言われています。
マイクは300Hz以下の音や3400Hzの音も拾っていますが、それらは全て音声認識する上では邪魔でしかありません。
ですので、まず3400Hz以下の音だけでフィルタリングして(ローパスフィルター)、300Hz以上の音だけでフィルタリングすると(ハイパスフィルター)、この範囲内の音だけが残ることになります。

# ハイパス&ローパスフィルター
from pydub.effects import low_pass_filter, high_pass_filter

audio = low_pass_filter(audio, 3400)
audio = high_pass_filter(audio, 300)

audio.export("生成物2.mp3")

スペクトル減算

先ほどのローパス&ハイパスフィルターで300Hzから3400Hzの範囲外の周波数データは全て削除されましたが、じゃあ300Hzから3400Hzの範囲内のデータが全部必要かと言われるとそんなことはありません。

300Hzから3400Hzの間にもノイズは含まれているので、それらを除去したいところですが、先ほどのように周波数を決めて一律カットというわけにはいきません。

そこで、大小含めた周波数の発現頻度分布(これを周波数スペクトルと言います)からノイズの周波数スペクトルを推定し,もとの周波数スペクトルから減算してやることでノイズをカットしてやろうというのがスペクトル減算の理屈です。

ノイズの周波数スペクトルが必要ですから、一定以上の長さのノイズ成分だけのデータ(noise profile)が必要となります。

以下のコードでは、用いたデータのうち0秒〜9秒の間は音声は入っていないので、この間のデータをノイズデータとして用いました

サンプルコード
# スペクトル減算
from scipy.fftpack import fft, ifft

audio_data = np.array(audio.get_array_of_samples()).astype(np.float32)

# audio_dataと同じ長さにノイズプロファイルをリピートして拡張
silence_segment:AudioSegment = audio[0:9000]
silence_data = np.array(silence_segment.get_array_of_samples()).astype(np.float32)
noise_profile = np.abs(fft(silence_data))
noise_profile = np.tile(noise_profile, int(np.ceil(len(audio_data) / len(noise_profile))))
noise_profile = noise_profile[:len(audio_data)]

# スペクトル減算
spectrum = fft(audio_data)
noise_reduction_factor = 0.9
reduced_spectrum = spectrum - noise_reduction_factor * noise_profile
reduced_spectrum = np.maximum(reduced_spectrum, 0)

reduced_audio_data = np.real(ifft(reduced_spectrum)).astype(np.int16)

# 処理された音声データを新しいAudioSegmentオブジェクトとして返す
audio = audio._spawn(reduced_audio_data.tobytes())
audio.export("生成物3.mp3")

おまけ:データサイズについて

これらの処理によってデータサイズがどれだけ変わったか、以下のように見ることができます。

# 各処理
audio.export("生成物2.mp3")
# ファイルサイズを取得
file_size = os.path.getsize("生成物2.mp3")
print(f"ファイルサイズ: {file_size / (1024 * 1024):.2f} MB")

ローパスフィルター及びハイパスフィルターをかけると元のデータの半分ほどの容量になりましたが、スペクトル減算では目立った容量の削減効果はありませんでした。

ちなみにこの後のfaster-whisperによるテキスト化において、容量の大きさは処理速度にそれほど影響はないらしいです。

結果

私のMacでは、ローパスフィルター及びハイパスフィルターをかけた時には音声がよりはっきりしましたが、スペクトル減算では逆に声が不明瞭になりました。

おそらくMacのマイク自体がノイズを除去する機能を有しており、録音された時点でノイズの少ないデータとなっているため、それ以上ノイズ除去しようとすると音声自体に悪影響してしまったものと思われます。

なので、次のfaster-whisperを使って音声をテキスト変換するときはローパスフィルター及びハイパスフィルターだけをすることにします。

Discussion