Closed5

「WebRTC VAD」を試す

kun432kun432

GitHubレポジトリ

https://github.com/wiseman/py-webrtcvad

py-webrtcvad

これは、WebRTCの音声活動検出器(VAD)に対するPythonインターフェースです。
Python 2およびPython 3に対応しています。

VAD <https://en.wikipedia.org/wiki/Voice_activity_detection>_ は、音声データの断片が有声音か無声音かを分類します。
これは、電話通信や音声認識に役立ちます。

Googleが WebRTC <https://webrtc.org/>_ プロジェクトのために開発したVADは、迅速でモダンかつ無償で利用可能であり、最高レベルの性能を持つと評されています。

今回はローカルのMacで試す。uvでプロジェクト+Python仮想環境を作成。

uv init -p 3.12.9 webrtcvad-work && cd webrtcvad-work

パッケージ追加。オーディオデータの処理やマイクでの取得も試したいので、numpyやsounddeviceも。

uv add webrtcvad sounddevice numpy
出力
 + numpy==2.2.4
 + sounddevice==0.5.1
 + webrtcvad==2.0.10

ではサンプルコード。無音のデータをWebRTC VADに渡して判定させてみる。

sample1.py
import webrtcvad

# VADオブジェクトの作成
vad = webrtcvad.Vad()

# VADの積極性モード(非音声のフィルタリングに対する厳しさ)を0〜3の範囲で設定
# - 0:最も控えめ。非音声と判断しにくくなる
#      →小さな発話などの取りこぼしが少なくなる
#      →小さなノイズに対しても「音声」と認識しやすくなる
# - 3:最も積極的。非音声と判断しやすくなる
#      →小さな音やノイズは「音声ではない」と判断しやすくなる
#      →小さな発話などでは取りこぼしが増える
# ※通常は1で良さそう
vad.set_mode(1)

# VADに10msの無音を渡す。結果はFalse(=発話を含まない)になるはず
# VADに渡す音声は以下のフォーマットである必要がある
# - 16ビット・モノラルPCM音声
# - サンプリングレート: 8000、16000、32000、48000 Hz のどれか
# - フレームの長さ: 10、20、30 ms
sample_rate = 16000  # サンプリングレート(Hz)
frame_duration = 10  # フレームの長さ(ms)
# フレームの長さ(サンプル数)=160サンプル
frame_length = int(sample_rate * frame_duration / 1000)

# 無音の判定
# - 16ビットPCMの無音(`b'\x00\x00'`)フレームを0.01秒分=160サンプル生成
frame_silence = b'\x00\x00' * frame_length

# 与えられたフレームが有音かを判定する
is_speech = vad.is_speech(frame_silence, sample_rate)
print('発話を含む?: {}'.format(is_speech))

実行

uv run sample1.py

・・・するもエラー

出力
ModuleNotFoundError: No module named 'pkg_resources'

これはsetuptoolsをインストールすればOKではあるが、

uv add setuptools

もう一つの方法としてはpy-webrtcvad-wheelsを使う。メンテナンス性やパッケージの新しさなども含めるとこちらを使うほうが良さそう。

https://github.com/daanzu/webrtcvad-wheels

py-webrtcvad-wheels

これは、WebRTC 音声活動検出器(VAD)への Python インターフェースです。Windows、macOS、Linux用のバイナリwheel付きの更新リリースを提供するために、wiseman/py-webrtcvadからフォークされました。また、追加の修正と改善も含まれています。

今回はこちらを使うことにする。

uv remove webrtcvad
uv add webrtcvad-wheels
出力
 + webrtcvad-wheels==2.0.14

再度実行

uv run sample1.py
出力
発話を含む?: False

有音の場合。サイン波を生成、WebRTCVADに渡して判定する。

import numpy as np
import webrtcvad

# 積極性モードは以下のようにVADオブジェクト作成時にも指定できる。
vad = webrtcvad.Vad(1)

sample_rate = 16000
frame_duration = 10
frame_length = int(sample_rate * frame_duration / 1000)

# VADに有音データを渡す。結果はTrue(発話を含む)になるはず
# - サイン波を生成して確認する
frequency = 440  # Hz(A4の音)
t = np.linspace(
    0,
    frame_duration / 1000,
    frame_length,
    endpoint=False
)
amplitude = 0.5  # 振幅(-1.0〜1.0の範囲)
waveform = amplitude * np.sin(2 * np.pi * frequency * t)
pcm_waveform = np.int16(waveform * 32767) # 16ビットPCMに変換
frame_speech = pcm_waveform.tobytes()

# 与えられたフレームが有音かを判定する
is_speech = vad.is_speech(frame_speech, sample_rate)
print('発話を含む?: {}'.format(is_speech))

実行

uv run sample2.py
出力
発話を含む?: True
kun432kun432

マイクからの入力をVADで判定する。

vad_from_mic.py
import sounddevice as sd
import webrtcvad
import numpy as np
import queue
import threading

SAMPLE_RATE = 16000
FRAME_DURATION = 20
FRAME_SIZE = int(SAMPLE_RATE * FRAME_DURATION / 1000)
CHANNELS = 1

vad = webrtcvad.Vad(1)

# 音声データを受け取るキュー
q = queue.Queue()

# 入力された音声を処理するコールバック関数
def callback(indata, frames, time, status):
    if status:
        print("ステータス:", status)

    # モノラルに変換
    audio = indata[:, 0]
    print("入力レベル(最大値、最小値): {:.3f} {:.3f}".format(np.max(audio), np.min(audio)))

    # 16ビットPCMに変換
    audio_bytes = (audio * 32767).astype(np.int16).tobytes()

    # キューに入れる
    q.put(audio_bytes)

# VAD判定用スレッド
def vad_worker():
    # 発話状態の初期化
    is_speech_prev = False
    while True:
        # キューから音声データを取得
        frame = q.get()

        # 不完全なフレームはスキップ
        if len(frame) != FRAME_SIZE * 2:
            continue

        # VAD判定
        is_speech = vad.is_speech(frame, SAMPLE_RATE)

        # 発話状態が変化した場合に出力
        if is_speech != is_speech_prev:
            if is_speech:
                print("音声検出")
            else:
                print("無音")
            is_speech_prev = is_speech

# VAD判定を別スレッドで実行
threading.Thread(target=vad_worker, daemon=True).start()

# マイクからの入力ストリーム開始
with sd.InputStream(
    channels=CHANNELS,
    samplerate=SAMPLE_RATE,
    blocksize=FRAME_SIZE,
    dtype='float32',  # sounddeviceのデフォルト
    callback=callback
):
    print("録音開始(Ctrl+Cで停止)")
    try:
        while True:
            pass
    except KeyboardInterrupt:
        print("\n停止します。")

実行

uv run vad_from_mic.py

適当に音声を発話すると以下のようにVADが検出しているのがわかる。

出力
録音開始(Ctrl+Cで停止)
入力レベル(最大値、最小値): 0.003 -0.003
入力レベル(最大値、最小値): 0.007 -0.006
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.055 -0.043
音声検出
入力レベル(最大値、最小値): 0.027 -0.034
入力レベル(最大値、最小値): 0.005 -0.007
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.003 -0.002
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.006 -0.004
入力レベル(最大値、最小値): 0.003 -0.005
無音
入力レベル(最大値、最小値): 0.002 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.000 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
(snip)
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.022 -0.021
入力レベル(最大値、最小値): 0.021 -0.040
音声検出
入力レベル(最大値、最小値): 0.031 -0.041
入力レベル(最大値、最小値): 0.061 -0.077
入力レベル(最大値、最小値): 0.065 -0.082
入力レベル(最大値、最小値): 0.132 -0.174
入力レベル(最大値、最小値): 0.143 -0.226
(snip)
入力レベル(最大値、最小値): 0.070 -0.087
入力レベル(最大値、最小値): 0.073 -0.099
入力レベル(最大値、最小値): 0.041 -0.090
入力レベル(最大値、最小値): 0.011 -0.013
入力レベル(最大値、最小値): 0.013 -0.013
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.004 -0.004
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
無音
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.001 -0.002

ただしよく見ると、発話が開始されてから少しあとに音声を検出している。つまり、実際に判定したタイミングから音声データを取得しても先頭が切れてしまうことになる。

出力
入力レベル(最大値、最小値): 0.007 -0.006
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.055 -0.043
音声検出
入力レベル(最大値、最小値): 0.027 -0.034
入力レベル(最大値、最小値): 0.005 -0.007
入力レベル(最大値、最小値): 0.004 -0.005
入力レベル(最大値、最小値): 0.003 -0.002

なので、一定のバッファをもたせるようにすれば良い。

vad_from_mic.py
import sounddevice as sd
import webrtcvad
import numpy as np
import queue
import collections
import threading

SAMPLE_RATE = 16000
FRAME_DURATION = 20
FRAME_SIZE = int(SAMPLE_RATE * FRAME_DURATION / 1000)
CHANNELS = 1
# プリロールフレーム数
PRE_ROLL_FRAMES = 5

vad = webrtcvad.Vad(1)

# 音声データを受け取るキュー
q = queue.Queue()

# プリロールバッファ
pre_roll_buffer = collections.deque(maxlen=PRE_ROLL_FRAMES)


# 入力された音声を処理するコールバック関数
def callback(indata, frames, time, status):
    if status:
        print("ステータス:", status)

    # モノラルに変換
    audio = indata[:, 0]
    print("入力レベル(最大値、最小値): {:.3f} {:.3f}".format(np.max(audio), np.min(audio)))

    # 16ビットPCMに変換
    audio_bytes = (audio * 32767).astype(np.int16).tobytes()

    # キューに入れる
    q.put(audio_bytes)


# VAD判定用スレッド
def vad_worker():
    # 発話状態の初期化
    is_speech_prev = False
    while True:
        frame = q.get()
        if len(frame) != FRAME_SIZE * 2:
            continue
        # プリロール用バッファに常に追加
        pre_roll_buffer.append(frame)

        # VAD判定
        is_speech = vad.is_speech(frame, SAMPLE_RATE)

        # 発話開始(無音→音声)
        if is_speech and not is_speech_prev:
            print("音声検出")
            print(f"バッファ長: {len(pre_roll_buffer)}")
            # プリロールバッファ各フレームの入力レベル(最大・最小)を出力
            for idx, buffered_frame in enumerate(pre_roll_buffer):
                # 16ビットPCMをfloat32に変換
                audio_np = np.frombuffer(buffered_frame, dtype=np.int16).astype(np.float32) / 32767.0
                print("  バッファ内フレーム{} 入力レベル(最大値、最小値): {:.3f}, {:.3f}"
                    .format(idx+1, np.max(audio_np), np.min(audio_np)))
        # 発話終了(音声→無音)
        if not is_speech and is_speech_prev:
            print("無音")
        
        # 発話状態を更新
        is_speech_prev = is_speech


# VAD判定を別スレッドで実行
threading.Thread(target=vad_worker, daemon=True).start()

# マイクからの入力ストリーム開始
with sd.InputStream(
    channels=CHANNELS,
    samplerate=SAMPLE_RATE,
    blocksize=FRAME_SIZE,
    dtype='float32',  # sounddeviceのデフォルト
    callback=callback
):
    print("録音開始(Ctrl+Cで停止)")
    try:
        while True:
            pass
    except KeyboardInterrupt:
        print("\n停止します。")

結果

入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.010 -0.011
入力レベル(最大値、最小値): 0.028 -0.035
音声検出
バッファ長: 5
  バッファ内フレーム1 入力レベル(最大値、最小値): 0.002, -0.003
  バッファ内フレーム2 入力レベル(最大値、最小値): 0.002, -0.003
  バッファ内フレーム3 入力レベル(最大値、最小値): 0.002, -0.003
  バッファ内フレーム4 入力レベル(最大値、最小値): 0.010, -0.011
  バッファ内フレーム5 入力レベル(最大値、最小値): 0.028, -0.035
入力レベル(最大値、最小値): 0.089 -0.193
入力レベル(最大値、最小値): 0.112 -0.273
入力レベル(最大値、最小値): 0.133 -0.277
入力レベル(最大値、最小値): 0.091 -0.114
入力レベル(最大値、最小値): 0.030 -0.029
(snip)
入力レベル(最大値、最小値): 0.026 -0.022
入力レベル(最大値、最小値): 0.021 -0.024
入力レベル(最大値、最小値): 0.009 -0.009
入力レベル(最大値、最小値): 0.003 -0.004
入力レベル(最大値、最小値): 0.003 -0.002
入力レベル(最大値、最小値): 0.001 -0.003
入力レベル(最大値、最小値): 0.002 -0.002
入力レベル(最大値、最小値): 0.001 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
無音
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.002 -0.003
入力レベル(最大値、最小値): 0.001 -0.002
入力レベル(最大値、最小値): 0.003 -0.003
入力レベル(最大値、最小値): 0.002 -0.004

音声検出前のバッファも取得できているので、何かしら後続の処理に回したい場合はこのバッファも含めて処理すればよい。

kun432kun432

結構センシティブで小さな音でも反応しがち。この点を考慮して使う必要がある。

このスクラップは5ヶ月前にクローズされました