音声 AI エージェント — 聞いて、調べて、話すパイプライン
音を数字に — サンプルレート・フレーム・dBFS、そして折り返して入り込む音
一言でいうと
マイクが渡してくれるのは、一定の間隔で測った空気圧の数列(PCM)です。1秒に何回測ったかがサンプルレート、数値1つの大きさがビット数です。音声パイプラインのすべての部品は、この数列を20 ms前後のフレームに分けてやり取りします。サンプルレートを変えるときにフィルターをかけないと、元になかった音が生じます。また、強さをdBFSで測ってはじめて、無音とノイズを1つのしきい値で分けられます。
なぜ必要なのか
音声アシスタントを初めて作るとき、まずモデルを選びます。ところが現場で最初に起きる事故は、モデルではなく音の形で起きます。ブラウザーは48 kHzで録音し、電話網は8 kHzで送り、このコースの認識モデルは16 kHzを受け取り、合成モデルは22.05 kHzで出力します。4つの数字が1回の通話の中にすべて出てきます。1か所でサンプルレートを間違えると、音が2倍速くなって認識率が0になり(モジュール3で実際に測ります。8 kHzを16 kHzだと偽ると、WERが100%になります)、バイト順序を間違えるとノイズだけが残ります。エラーは出ません。数字は相変わらず数字だからです。
どう動くのか
サンプルレートとナイキスト。1秒にfs回測ると、fs/2 Hzまでの成分しか表現できません(標本化定理)。16 kHzで測れば8 kHzまでです。人の話し声の聞き取りに重要な成分は、大部分がそれより下にあるため、音声認識モデルは16 kHzを標準として使います。固定電話(ITU-T G.711)は8 kHzで測って、300–3,400 Hzだけを運びます。電話の声がこもって聞こえるのはそのためです。
折り返し(エイリアシング)。48 kHzから16 kHzに下げるとき、3つおきに1つ取り出すだけだと、8 kHzより上にあった成分が消えずに、下へ折り返して入ってきます。12 kHzの成分は16 − 12 = 4 kHzとして現れます。元になかったピーという音です。そのため、間引く前に、新しいナイキスト周波数(8 kHz)より低い位置で切るローパスフィルターを先にかけます。ラボの原本には、これを確認できるように12 kHzのパイロットトーン(-26 dBFS)を混ぜてあります。そのまま間引くと4 kHzに-29 dBFSが生じ、127タップの窓付きsincフィルターでフィルタリングしてから間引くと-65 dBFSまで下がります(このイメージで実測)。逆に、8 kHzを16 kHzに上げても、4 kHzより上の音が生まれるわけではありません。なかった情報は作られません。
ビット深度とバイト順序。16ビットPCMは、-32768から32767までの整数です。計算は32768で割った[-1, 1)の実数で行い、ファイルとネットワークには整数で書きます。バイトは小さいほうが先(little-endian)になるのが普通なので、形式名がs16leです。2つのチャンネルをモノラルに混ぜるときは平均を取ります。足すだけだと、2つのチャンネルが0.8のときに1.6になって、範囲を超えて切り取られます(クリッピング)。
フレームと強さ。音を20 ms(16 kHzで320サンプル、640バイト)ずつ区切って扱います。人の話し声の性質は数十msの間ほとんど変わらず、リアルタイム伝送(WebRTCのOpusなど)でも20 msがよく使われます。フレームの強さは、RMS(二乗平均平方根)をdBFS、つまり最大値(1.0)を0 dBとした対数目盛りで表します: 20·log10(RMS)。-40 dBFSは最大の1%、-60 dBFSは0.1%です。掛け算が足し算になるので、「話し声は-20付近、部屋の音は-50付近」のように、しきい値1つで分けられます。
SNR。信号対雑音比は電力の比です: 10·log10(P信号 / Pノイズ)。振幅比で書くと20·log10になります。両者を混ぜると、10 dBを入れるつもりが5 dBや20 dBを入れてしまいます。ラボの反例が、まさにこれです。
現場での姿
このコースのリアルタイム通信コースとの境界がここです。そのコースがブラウザーのマイクをWebSocketでサーバーまで運び、このコースは「16 kHz・モノラル・s16le・20 msフレームが到着する」という約束から始まります。約束の4項目のうち1つでもずれると、受け取る側はエラーなしでおかしな音を聞くことになります。そのため、形式をメッセージごとに載せなくても、接続を開くときに1回はやり取りして、お互いに確認するのが望ましいです。
LabHubの言語会話練習機能は逆の方式を使います。ブラウザーのMediaRecorderで1文を丸ごと録音して送ります(backend/app/lang_talk.py、static/lang/talk.js)。1回に送る録音の上限を「15秒 × 16 kHz × 16ビットに余裕を持たせた2 MB」としたのも、この計算から出てきました。丸ごと送ると実装は単純ですが、人が話し終えてから認識が始まります。フレームに分けて送る理由は、その待ち時間をなくすためです(モジュール3・8)。
フレームサイズにもトレードオフがあります。フレームが小さいほど、1つの断片を集めるのにかかる時間(20 msフレームなら20 ms)は短くなりますが、メッセージ数が増えて、ヘッダーやシステムコールの占める割合が大きくなります。逆に100 msに大きくすると、メッセージは5分の1に減りますが、すべての段階が最低でも100 msずつ遅れて音を受け取ります。このコースのストリーミングASRは100 msの断片で受け取ります。WebSocketのフレーム5つをまとめて1回で入れる形です。
次のラボですること
48 kHzステレオの原本のヘッダーを読み、モノラルに混ぜ、フィルターなしで16 kHzに下げて4 kHzの折り返しを測り、ローパスフィルターで正しく下げます。20 msフレームのdBFSを求めて無音区間を見つけ、シードを固定したノイズをSNR 10 dBで混ぜたあと、WebSocketが運ぶ640バイトのフレームに切って保存します。