音声 AI エージェント — 聞いて、調べて、話すパイプライン
48 kHz の原音を 16 kHz のフレームへ — 折り返しを測って防ぐ
目標
48 kHzステレオの録音を、音声モデルが受け取る形(16 kHz・モノラル・16ビット・20 msフレーム)に変換しながら、フィルターなしのリサンプリングが生む折り返し音と、dBFS・SNRを数字で確認します。
なぜ重要なのか
音声パイプラインで最もよくある事故は、モデルではなく音の形で起きます。サンプルレート・チャンネル・バイト順序がずれてもエラーは出ず、認識率だけが静かに崩れます。原本(/opt/lab/fixtures/voice/audio/caller_48k.wav)には、12 kHzのパイロットトーンをわざと混ぜてあります。16 kHzに下げるときにフィルターをかけないと、このトーンが4 kHzに折り返されて聞こえる音になります。採点ツールは、保存したファイルを読み直して、4 kHz成分の強さ、フレームごとのdBFS、実際のSNR、バイト配列を直接測ります。
ステップ
/opt/lab/fixtures/voice/audio/caller_48k.wavのヘッダーを読み、/root/voice/audio/info.jsonにsample_rate・channels・sample_width_bytes・frames・duration_sを書いてください。- 2つのチャンネルの平均でモノラルを作り、48 kHzのまま
/root/voice/audio/mono48k.wavに保存してください。 - フィルターをかけずに3つおきに1つ取り出して(
x[::3])、/root/voice/audio/naive16k.wav(16000 Hz)に保存してください。 - 8 kHzより下だけを通すローパスフィルターをかけたあと、3つおきに取り出して
/root/voice/audio/mono16k.wav(16000 Hz)に保存してください。 mono16k.wavを20 ms(320サンプル)のフレームに分けて、各フレームのRMS dBFSを/root/voice/audio/levels.csv(index,start_s,dbfs)に書いてください。- -40 dBFS未満が200 ms(10フレーム)以上続いた区間を、
/root/voice/audio/silence.jsonに[{"start_s":…,"end_s":…}]の形で書いてください。 - シード7の標準正規ノイズ(
np.random.default_rng(7).standard_normal(n))を、SNR 10 dBになるように混ぜて/root/voice/audio/noisy10.wavに保存し、保存したファイルで測り直した値を/root/voice/audio/snr.jsonに書いてください。 mono16k.wavをs16leの20 msフレーム(640バイト)でつなげて書いた/root/voice/audio/stream.pcmと、形式を書いた/root/voice/audio/stream.jsonを作ってください。最後のフレームの足りない部分は0で埋めます。
参考
- 読み書き: 標準ライブラリの
waveとnumpyで十分です。イメージに入っているヘルパーfrom voicekit import read_wav, write_wav, to_int16, dbfsも使えます(モノラル平均・16ビット変換をしてくれます)。 - 16ビットのサンプルは
np.frombuffer(raw, dtype="<i2")で読み、32768で割ります。ステレオは(프레임, 채널)にreshapeします(プレースホルダーはフレーム数とチャンネル数です)。 - よくある間違いは、長さを
frames / sample_rate / channelsで計算してしまうこと(framesはすでにチャンネル単位にまとめた数です)、2つのチャンネルを足すだけにしてしまうこと(範囲を超えます)、SNRを振幅比で合わせてしまうこと(10 dBが5 dBになります)です。 - 概念: Nyquist–Shannon sampling theorem・ITU-T G.711・Python wave
ヘッダーから読む
/opt/lab/fixtures/voice/audio/caller_48k.wavをwaveで開き、/root/voice/audio/info.jsonにsample_rate・channels・sample_width_bytes・frames・duration_s(秒、小数第3位)を書いてください。
wave.open(...).getframerate()・getnchannels()・getsampwidth()・getnframes()が4つの値を返します。framesは「チャンネルごとに1つずつまとめたサンプル」の数なので、長さはframes ÷ サンプルレートです。
2つのチャンネルを平均してモノラルにする
原本の2つのチャンネルを平均してモノラルにし、48000 Hzのまま/root/voice/audio/mono48k.wav(16ビット)に保存してください。
(フレーム, チャンネル)配列のaxis=1の平均です。足すだけだと、大きな音で1.0を超えて切り取られます。書き込むときは32768を掛けて四捨五入し、-32768から32767の範囲に収めます。
フィルターをかけずに間引いてみる
mono48k.wavから3つおきに1つ(x[::3])取り出して、/root/voice/audio/naive16k.wav(16000 Hzモノラル)に保存してください。わざと間違った方法です。
48000 ÷ 3 = 16000なので、間引くだけでサンプルレートは合います。ところが、原本の12 kHzのトーンはどこへ行ったのでしょうか。採点メッセージが4 kHz成分の強さを教えてくれます。
フィルターをかけて下げる
mono48k.wavに、新しいナイキスト周波数(8 kHz)より低いカットオフ周波数のローパスフィルターをかけたあと、3つおきに取り出して/root/voice/audio/mono16k.wav(16000 Hzモノラル)に保存してください。4 kHzの折り返し成分が、そのまま間引いた場合より30 dB以上小さくなければなりません。
窓をかけたsincが最も簡単なローパスフィルターです: h = 2·fc·sinc(2·fc·n)·hamming、fcはカットオフ周波数 ÷ 48000です。hの合計で割ってゲインを1に合わせ、np.convolve(x, h, mode='same')でかけます。タップ数を増やすほど、遮断が急になります。
20 msフレームの強さ(dBFS)
mono16k.wavを320サンプル(20 ms)のフレームに分けて(末尾の足りない断片は捨てます)、フレームごとにindex,start_s,dbfsを/root/voice/audio/levels.csvに書いてください。dBFSは20·log10(RMS)で、完全な無音は-120です。
RMSは二乗の平均の平方根です。サンプルを32768で割った実数で測ると、0 dBFSが最大になります。10·log10を使うと値が半分になります(電力ではなく振幅のRMSなので20です)。
無音区間を探す
levels.csvから、-40 dBFS未満が10フレーム(200 ms)以上続いた区間を、/root/voice/audio/silence.jsonに[{"start_s": …, "end_s": …}]の形で書いてください。終わりは「初めてしきい値を超えたフレームの開始」で、ファイルの末尾まで続く場合は最後のフレームの終わりです。
しきい値より下に入ったフレーム番号を覚えておき、しきい値より上に出た瞬間に長さを確認します。リストの末尾に大きな値を1つ付け足せば、最後の区間も同じコードで閉じられます。
SNR 10 dBでノイズを混ぜる
mono16k.wavと同じ長さのnp.random.default_rng(7).standard_normal(n)のノイズを、SNR 10 dB(電力比)になるように倍率を合わせて足し、/root/voice/audio/noisy10.wavに保存してください。そして保存したファイルを読み直して測ったSNRを、/root/voice/audio/snr.jsonのmeasured_dbに書いてください(target_dbは10です)。
SNR = 10·log10(P信号 / Pノイズ)で、Pは二乗平均です。ノイズに掛ける倍率はsqrt(P信号 / 10^(10/10) / Pノイズ)です。保存すると16ビットに丸められるので、読み直してnoisy − cleanで測ると正確です。
WebSocketが運ぶ640バイトのフレームに
mono16k.wavのサンプルをs16le(16ビット、小さいバイトが先)で20 ms(320サンプル = 640バイト)ずつつなげて書いた/root/voice/audio/stream.pcmを作り(最後のフレームは0で埋めます)、/root/voice/audio/stream.jsonにformat("s16le")・sample_rate・channels・frame_ms・frame_bytes・framesを書いてください。
WAVヘッダーなしで、サンプルだけを書きます。np.int16配列の.tobytes()はマシンのバイト順序に従うので、dtypeを'