【python】Realtime-VAP（発話交代予測）を試す

# はじめに
音声対話の高速化に役立ちそうな[Realtime-VAP](https://github.com/inokoj/VAP-Realtime)（Realtime Voice Activity Projection; rVAP）を試してみます。


<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">リアルタイムVAPのレポジトリに相槌予測のプログラム・モデルを追加しました。結構いい感じの相槌（応答系、感情表出系）が予測できると思います😃 <a href="https://t.co/yTSt2kVnqr">https://t.co/yTSt2kVnqr</a></p>&mdash; Koji Inoue / 井上 昂治 (@inokoj) <a href="https://twitter.com/inokoj/status/1830444491599458492?ref_src=twsrc%5Etfw">September 2, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

リポジトリに含まれるassetのモデルをそのまま使用する場合、用途は学術目的のみとなりますので注意してください。

rVAPは相槌（タイミング、種別）予測とターンテイキング予測の機能があり、今回はターンテイキングを試してみます。
まずサンプルコードをそのまま実行し、その後、音声認識等と組み合わせることを見据えてスレッド化する実装をやってみます。

# サンプルコード実行

rVAPリポジトリのREADMEに従ってほぼ問題なく実行できました。
なお、仮想環境構築にuvを使っていますが、普通にpipでも動くと思います。

## インストール
```sh
git clone https://github.com/inokoj/VAP-Realtime.git
cd VAP-Realtime
uv init .
uv add -r requirements.txt
uv pip install -e .
```

なお`uv add`を使うとtorchのv2.3以上のインストールに失敗します。エラーが出た場合は、requirements.txtのtorchのバージョンを修正してください。
またなお、筆者の環境ではlangchain 0.3.9も同時に使っており、langchainとtorchが要求するnumpyのバージョンの競合が起こりました。torchではnumpy v1が必要であり、幸いlangchain 0.3.9もnumpy 1.26がカバーされていたので、1.26をインストールすることで解決できました。

修正例です。

```:requirements.txt
# For VAP main
#torch>=2.2.0
torch==2.2
#numpy==1.23.5
numpy=1.26.2
```

## VAPサーバ
VAPサーバを実行します。

```sh
cd rvap/vap_main
uv run vap_main.py \
    --vap_model ../../asset/vap/vap_state_dict_jp_20hz_2500msec.pt \
    --cpc_model ../../asset/cpc/60k_epoch4-d0f474de.pt \
    --port_num_in 50007 \
    --port_num_out 50008 \
    --vap_process_rate 20 \
    --context_len_sec 2.5
```

以下のような出力が出ます。

```
Device:  cpu
########################################
Load pretrained CPC
########################################
Froze EncoderCPC!
########################################
Load pretrained CPC
########################################
Froze EncoderCPC!
freeze encoder
Froze EncoderCPC!
Froze EncoderCPC!
/path/to/VAP-Realtime/rvap/vap_main/vap_main.py:485: DeprecationWarning: setDaemon() is deprecated, set the daemon attribute instead
  t_server_out_connect.setDaemon(True)
/path/to/VAP-Realtime/rvap/vap_main/vap_main.py:490: DeprecationWarning: setDaemon() is deprecated, set the daemon attribute instead
  t_server_out_distribute.setDaemon(True)
[IN] Waiting for connection of audio input...
```
## 入力サーバ
VAPサーバに音声情報を入力するサーバを実行します。
wav入力とマイク入力の2種類あり、今回はマイク入力を試したいのでmic.pyを実行します。なお末尾に`bc`がついているpythonファイルはbackchannel予測用です。

```sh
cd ../../input/
uv run mic.py \
    --server_ip 127.0.0.1 \
    --port_num 50007
```

### 入力サーバの出力

```
    --server_ip 127.0.0.1 \
    --port_num 50007
----------------------------------
Server IP: 127.0.0.1
Server Port: 50007
----------------------------------
/path/to/VAP-Realtime/input/mic.py:123: DeprecationWarning: setDaemon() is deprecated, set the daemon attribute instead
  thread_server.setDaemon(True)
[COMMAND] Waiting for connection of command...
Connected to the server
```
### VAPサーバ側の出力
```
[IN] Connected by ('127.0.0.1', 61630)
```

ときどき以下のような出力もされるようになります。
```
[VAP] Average processing time: 0.04375 [sec], #process/sec: 19.980
```

なおrVAPは1マイク1話者のステレオ入力を前提としますが、mic.pyではモノラル入力に2つめのマイクの代わりとしてゼロ埋めした値を足して、VAPサーバに送っているようです。つまり話者1が話し、話者2は沈黙しているという状況です。音声対話ボットを作る場合には、これでよさそうです。
## 出力サーバ
VAPの解析結果（出力）を受け取るためのサーバを実行します。
guiモードとコンソールモードが有り、今回はコンソールにします。
また、入力サーバと同様、bcとついているものはbackchannel用です。

```sh
cd ../../output/console.py
```

### 出力サーバの出力
```
% uv run console.py 
定期的に以下のように、発話交代予測の結果が定期的に出力されます。

-----------------------
t: 1733729355.8128252
x1: [-4.1403935028938577e-05, -7.00072996551171e-05, -2.890511132136453e-05, 0.00011681692558340728, 0.00012300994421821088, 0.00015123601770028472, 0.0001947516284417361, 0.0001602205156814307, 0.0001754631957737729, 0.00021711982844863087]
x2: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
p_now: [0.8086234331130981, 0.1913648098707199]
p_future: [0.6775044798851013, 0.322486013174057]
-----------------------
t: 1733729356.814435
x1: [-0.0005671053077094257, -0.0006474241963587701, -0.0006492701941169798, -0.0006194253801368177, -0.0006030529038980603, -0.00044184699072502553, -0.000186782272066921, -8.981514838524163e-07, -3.6942237784387544e-06, -0.00011157258995808661]
x2: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
p_now: [0.6516389846801758, 0.3483458161354065]
p_future: [0.516667902469635, 0.48332181572914124]
```

出力項目のうちp_futureがリストで1番目、2番目が発話を開始する確率を表しています。
mic.pyはマイク入力を1人目の話者とみなして、2人目は0埋め（無音）としてサーバに送信するため、音声対話ボットに応用する場合は、2番目の確率がある程度高くなったら、ボットが発話を開始する、みたいに使えば良さそうです。


### VAPサーバの出力

```
[OUT] Connected by ('127.0.0.1', 62249)
[OUT] Current client num = 1
```

# コード修正
次のステップに進む前にvapサーバのコードを少し修正します。
リポジトリからダウンロードした状態のvap_main.pyだと、出力サーバを落としたときにエラーを吐いてしまい、２回目以降、出力サーバに結果を正しく返してくれなくなります。

再現方法としては、VAPサーバ、入力サーバ、出力サーバをそれぞれ別のコンソールで起動した状態で、出力サーバをctrl+Cで停止します。
すると、VAPサーバに以下の標準出力が表示されます。

```
[OUT] Connected by ('127.0.0.1', 62278)
[OUT] Current client num = 1
Exception in thread Thread-2 (proc_serv_out_dist):
Traceback (most recent call last):
  File "/path/to/VAP-Realtime/rvap/vap_main/vap_main.py", line 432, in proc_serv_out_dist
    conn.sendall(data_sent_all)
BrokenPipeError: [Errno 32] Broken pipe

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/path/to/python/versions/3.11.2/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
    self.run()
  File "/path/to/python/versions/3.11.2/lib/python3.11/threading.py", line 975, in run
    self._target(*self._args, **self._kwargs)
  File "/path/to/Care/VAP-Realtime/rvap/vap_main/vap_main.py", line 434, in proc_serv_out_dist
    print('[OUT] Disconnected by', conn.getpeername())
                                   ^^^^^^^^^^^^^^^^^^
OSError: [Errno 22] Invalid argument
```

VAPサーバのproc_serv_out_dist関数で、conn.getpeername()を実行しようとして落ちているようです。
このエラーがでると、２回目以降、console.pyを起動しても出力を取得できなくなります（コンソールの出力的には接続は認識しているっぽいですが）。

これは出力サーバが落ちた時点でconn.getpeernameが実行できなくなっているので起こるエラーのようです。
対策としては、conn.getpeernameをエラー時に呼ばないようにすればよいです。丁寧にやるなら、conn.getpeernameを呼べるエラーの場合には、呼ぶように分岐しても良いと思います。

以下のように修正します。433行目付近です。

```python:rvap/vap_main/vap_main.py
            except:
                #print('[OUT] Disconnected by', conn.getpeername())
                print('[OUT] Disconnected')
                list_socket_out.remove(conn)
                continue
```

これで出力サーバを何度接続し直してもエラーが発生しなくなりました。

# pythonでスレッド化
VAPを音声対話システムの中で使いたい場合、音声認識と並列で実行できると便利です。音声認識は発話完了の予測が遅い欠点があるため、これをVAPで補うことができます。
そこで、VAPサーバの出力を取得するconsole.pyをスレッド化してみます。

## 準備
他のリポジトリからVAPのプログラムを使う想定でやってみます。
まずメインの処理を実行するリポジトリを作成し、そのルートに移動し、仮想環境を作成します。
その後、gitでrVAPをそのリポジトリの中にダウンロードし、必要なライブラリをインストールします。

```sh
mkdir wrap_vap
cd wrap_vap
uv init .
git clone https://github.com/inokoj/VAP-Realtime.git
# requirements.txtのバージョン調整を必要に応じて実施
uv add -r VAP-Realtime/requirements.txt
uv pip install -e VAP-Realtime # addできないのでpipする
```

VAPサーバと入力サーバ（マイク）をそれぞれ別のコンソールで実行します。

```sh
cd VAP-Realtime/rvap/vap_main
uv run vap_main.py \
    --vap_model ../../asset/vap/vap_state_dict_jp_20hz_2500msec.pt \
    --cpc_model ../../asset/cpc/60k_epoch4-d0f474de.pt \
    --port_num_in 50007 \
    --port_num_out 50008 \
    --vap_process_rate 20 \
    --context_len_sec 2.5
```

```sh
cd VAP-Realtime/input
uv run mic.py \
    --server_ip 127.0.0.1 \
    --port_num 50007
```

## 並列実行プログラム
出力サーバ（console.py）の処理をスレッド化します。

```Python

import threading
import time
import sys
from multiprocessing import Manager
import dotenv

dotenv.load_dotenv()

import socket
import rvap.common.util as util


class RVAPOutput(threading.Thread):
    def __init__(self, server_ip='127.0.0.1', 
                 port_num=50008, 
                 callback=None,
                 timeout: float = 1.0):
        super().__init__(daemon=True)
        self.server_ip = server_ip
        self.port_num = port_num
        self.callback = callback
        self.stop_event = threading.Event()
        self.timeout = timeout

    def run(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock_server:
            sock_server.connect((self.server_ip, self.port_num))
            #sock_server.settimeout(self.timeout)
            try:
                while not self.stop_event.is_set():
                    try:
                        data_size = sock_server.recv(4)
                        size = int.from_bytes(data_size, 'little')

                        data = sock_server.recv(size)
                        while len(data) < size:
                            data += sock_server.recv(size - len(data))

                        vap_result = util.conv_bytearray_2_vapresult(data)

                        if self.callback:
                            self.callback(vap_result)

                    except Exception as e:
                        print('Disconnected from the server')
                        print(e)
                        break
            finally:
                sock_server.close()

    def stop(self):
        self.stop_event.set()
    
def main():
    import config
    shared_dict = Manager().dict()
    

    def rvap_callback(result: dict):
        nonlocal shared_dict
        print("[RVAP]:", result["p_future"])
        shared_dict["rvap_result"] = result

    rvap = RVAPOutput(config.rvap_ip, config.rvap_port, callback=rvap_callback)

    rvap.start()
    
    print("Press Ctrl+C to stop.")
    try:
        while True:
            time.sleep(0.5)
    except KeyboardInterrupt:
        print("KeyboardInterrupt detected. Stopping...")
        rvap.stop()

    rvap.join()
    
    print("Main thread exiting.")
    sys.exit(0)
    


if __name__ == "__main__":
    main()
```

プログラムを実行すると以下のようにVAPの結果が標準出力に吐かれます。

```
Press Ctrl+C to stop.
[RVAP]: [0.5313989520072937, 0.46858716011047363]
[RVAP]: [0.4815250337123871, 0.5184617638587952]
[RVAP]: [0.47212791442871094, 0.5278592705726624]
[RVAP]: [0.46201440691947937, 0.5379725694656372]
[RVAP]: [0.4168122112751007, 0.5831741690635681]
[RVAP]: [0.3997649550437927, 0.6002211570739746]
[RVAP]: [0.4203703999519348, 0.5796152353286743]
[RVAP]: [0.41406455636024475, 0.585921049118042]
```
callback関数でVAPの結果に対する処理を自由に定義できます。
今は標準出力とnonlocalの変数に保存するということをやっています。
nonlocalの変数に保存されているため、他のスレッドと組み合わせてVAPの結果を使うことができます。

# おわりに
Realtime-VPAのお試しとスレッド化をやってみました。動くところまで行けてよかったです。
出力を眺めた感じ、発話交代予測の精度も高い気がしました（2024/12/10追記：と思いましたがもうちょっと試していると思ったよりも話者2=Botのスコアが高くならない時もあり、仕様や傾向を確認していく飛鳥がありそうです）
次は、音声認識との並列実行を試してみたいと思います。

# 参考
## 論文
Koji Inoue, Bing'er Jiang, Erik Ekstedt, Tatsuya Kawahara, Gabriel Skantze
Real-time and Continuous Turn-taking Prediction Using Voice Activity Projection
International Workshop on Spoken Dialogue Systems Technology (IWSDS), 2024
https://arxiv.org/abs/2401.04868
## リポジトリ
https://github.com/inokoj/VAP-Realtime
## ライセンス
Realtime-VAPのリポジトリのコードはMITライセンスですが、assetディレクトリにある学習済みモデルは学術目的のみに使用可能です。

ただしasset/cpc/60k_epoch4-d0f474de.ptについては、オリジナルのCPCプロジェクトからダウンロードしたものであり、そのライセンスに従ってください。
[CPC_audioのリポジトリ](https://github.com/facebookresearch/CPC_audio)を読むと「CPC_auidoはMITライセンス」とREADME.mdに記載されています。
ただしCPCのモデル（.pt）そのものはこのリポジトリにも含まれておらず、hubからDLしているようです。どちらかといえば、リポジトリのライセンスは外部からDLするファイルには適用されないことが多いので、CPCモデルがMITライセンスかどうかはわかりません。DL元はfacebook管理のページのようでライセンス情報は見つかりませんでした。ソースコードの中でDLコードまで具体的に記述している以上、お試し程度なら問題ない可能性が高そうですが、商用などがっつり使いたいときはきちんと情報を探したほうがよさそうです。