LabHub
はじめる
学ぶ 学習パス コース

音声 AI エージェント — 聞いて、調べて、話すパイプライン

48 kHz の原音を 16 kHz のフレームへ — 折り返しを測って防ぐ

LabHubで続きを見る

目標

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、バイト配列を直接測ります。

ステップ

  1. /opt/lab/fixtures/voice/audio/caller_48k.wavのヘッダーを読み、/root/voice/audio/info.jsonにsample_rate・channels・sample_width_bytes・frames・duration_sを書いてください。
  2. 2つのチャンネルの平均でモノラルを作り、48 kHzのまま/root/voice/audio/mono48k.wavに保存してください。
  3. フィルターをかけずに3つおきに1つ取り出して(x[::3])、/root/voice/audio/naive16k.wav(16000 Hz)に保存してください。
  4. 8 kHzより下だけを通すローパスフィルターをかけたあと、3つおきに取り出して/root/voice/audio/mono16k.wav(16000 Hz)に保存してください。
  5. mono16k.wavを20 ms(320サンプル)のフレームに分けて、各フレームのRMS dBFSを/root/voice/audio/levels.csv(index,start_s,dbfs)に書いてください。
  6. -40 dBFS未満が200 ms(10フレーム)以上続いた区間を、/root/voice/audio/silence.jsonに[{"start_s":…,"end_s":…}]の形で書いてください。
  7. シード7の標準正規ノイズ(np.random.default_rng(7).standard_normal(n))を、SNR 10 dBになるように混ぜて/root/voice/audio/noisy10.wavに保存し、保存したファイルで測り直した値を/root/voice/audio/snr.jsonに書いてください。
  8. mono16k.wavをs16leの20 msフレーム(640バイト)でつなげて書いた/root/voice/audio/stream.pcmと、形式を書いた/root/voice/audio/stream.jsonを作ってください。最後のフレームの足りない部分は0で埋めます。

参考

ヘッダーから読む

/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を'