👻

Style-Bert-Vits2のFastAPIを用いてAIキャラクターと会話しよう

2024/09/25に公開

Style-Bert-Vits2(SBV2)とは

litaginさんがBert-Vits2というTTSを日本人向けに改変してくれたリポジトリ?
という認識です。(使い倒している癖にあまりよくわかっていない)
文章の感情表現的なものを読み取って自然な音声合成をしてくれます。

https://github.com/litagin02/Style-Bert-VITS2/tree/master

AIキャラクターと会話したい

AIキャラクターと会話したいやん。
ということでキャラクターを生み出しました。

https://hub.vroid.com/characters/2966590661602996553/models/1754342469666240231

モデルも作っちゃおう

SBV2のモデルも学習しました。(ついでにRVCも)
※許可を得たうえでデータセットを作成しました。

https://dennotenshi.booth.pm/items/5475738

有料版もありますが無料版で十分楽しめます。
マージも商用利用も基本的にOKです。

会話しちゃった

以下は実際の会話画面兼、今回のスクリプトの説明動画になります。

https://youtu.be/43g4u3Pei-Y?si=Il4ygN2nEiRYwoRk

会話しよう【スクリプト編】

githubにpushするほどのものでもなかったのでスクリプトを共有します。
以下がpythonスクリプトとrequirements.txtです。
長くて申し訳ないのですが、使い方はのちに説明します。

少し昔に作ったものをやや改変しただけなので改善点もあると思います。
適宜変更して使ってくれて構いません。
なるべく初学者にも再現できるように説明しようと思います。

kaiwa.py

import sounddevice as sd
import numpy as np
import tempfile
import soundfile as sf
import time
import openai
import os
import sys
import queue
import requests
import threading
import re
import webrtcvad
from scipy.io import wavfile

# Set the API endpoint URL
API_URL = "http://127.0.0.1:5000/voice"  # Adjust the port number if needed
print(sd.query_devices())
input_device_id = int(input("Enter the desired input device ID, 0 for the default device: "))
output_device_id = int(input("Enter the desired output device ID, 0 for the default device: "))


#以下のような方法でapiキーを読み込む、環境変数に保存するか直接書き込むか
#実際に使うときはコメントアウトを外してください

#openai.api_key = os.environ["OPENAI_API_KEY"]
#openai.api_key = "your_api_key"
client = openai.Client(api_key=openai.api_key)

# 録音の設定
# VADの初期化
vad = webrtcvad.Vad(2)  # 2は感度のレベル
fs = 16000  # サンプリングレート
duration = 30  # 最大録音時間(秒)
silence_threshold = 0.01  # 無音の閾値
silence_duration = 0.5  # 無音の最大継続時間(秒)

# 録音データを保存するキュー
q = queue.Queue()

# 音声データのコールバック関数
def callback(indata, frames, time, status):
    if status:
        print(status, file=sys.stderr)
    q.put(indata.copy())

# 無音検出関数
def detect_silence(sound, threshold):
    return np.sqrt(np.mean(sound**2)) < threshold

# 録音関数
def record_audio():
    print("録音を開始します。話しかけてください。")
    with sd.InputStream(device=input_device_id, samplerate=fs, channels=1, callback=callback) as stream:
        silence_counter = 0
        recording = []
        start_time = time.time()
        voice_detected = False  # 声が検出されたかどうかのフラグ
        while True:
            if not q.empty():
                data = q.get()
                if detect_silence(data, silence_threshold):
                    if voice_detected:  # 声が検出された後に無音を検出した場合
                        silence_counter += len(data) / fs
                    # 声が検出されていない場合は無音カウンターを増やさない
                else:
                    silence_counter = 0
                    voice_detected = True  # 声が検出された
                recording.append(data)
                if silence_counter >= silence_duration:  # 無音の最大継続時間を超えたら
                    if voice_detected:  # 声が検出されていた場合のみ録音を終了
                        break
                if (time.time() - start_time) >= duration:  # 最大録音時間に達したら
                    break
        return np.concatenate(recording, axis=0) 

# VADのための関数
def frame_generator(frame_duration_ms, audio, sample_rate):
    # 確認: sample_rate と frame_duration_ms がスカラー値であることを確認
    assert isinstance(sample_rate, int), "sample_rate must be an integer value"
    assert isinstance(frame_duration_ms, int), "frame_duration_ms must be an integer value"
    
    n = int(sample_rate * frame_duration_ms / 1000)  
    offset = 0
    while offset + n < len(audio):
        yield audio[offset:offset + n]
        offset += n

def vad_collector(sample_rate, frame_duration_ms, vad, audio):
    # フレームジェネレータからフレームを取得
    frames = frame_generator(frame_duration_ms, audio, sample_rate)
    voiced_frames = []  # 音声が含まれるフレームを保存するリスト
    for frame in frames:
        is_speech = vad.is_speech(frame.tobytes(), sample_rate)  # 現在のフレームが音声かどうかを判定
        if is_speech:
            voiced_frames.append(frame)  # 音声フレームをリストに追加
        elif voiced_frames:
            yield b''.join(voiced_frames)  # 音声フレームをバイト列として結合して出力
            voiced_frames = []  # 音声フレームリストをリセット
    if voiced_frames:
        yield b''.join(voiced_frames)  # 最後の音声フレームがあれば出力

# 音声認識関数
def recognize_speech(audio_data):
    # 録音データを16ビットpcm形式で一時ファイルに保存
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
        sf.write(tmp_file, audio_data, fs, subtype='PCM_16')
        tmp_file_path = tmp_file.name  # ファイルパスを保存

    # VADを使用して無音部分を検出し、音声がある部分のみを抽出
    audio = wavfile.read(tmp_file_path)[1]  # wavfile.readはタプルを返すため、[1]でオーディオデータを取得
    sample_rate = fs
    segments = vad_collector(sample_rate, 10, vad, audio)

    # 音声がある部分のみを連結
    segments_list = [np.frombuffer(segment, dtype=np.int16) for segment in segments]
    if segments_list:  # セグメントリストが空でない場合のみ連結を行う
        voiced_audio = np.concatenate(segments_list)
    else:
        print("音声が検出されませんでした。")
        return None  # または適切なエラー処理を行う

    # recognize_speech関数内
    with open(tmp_file_path, 'rb') as f:  # 'rb'は読み取り専用のバイナリモードを意味します
        response = client.audio.transcriptions.create(
            model="whisper-1",
            file=f,
            language="ja"
        )

    # 使用後にファイルを明示的に削除
    os.remove(tmp_file_path)
    return response.text

# GPT APIを用いたテキスト生成関数
def generate_text(messages):
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages
    )
    # 応答テキストを取得
    return response.choices[0].message.content

# メインループ
messages = [
        {"role": "system", "content": """
        英語も全て日本語表記で返答する。
        できるだけ短く簡潔な返答をする。
         
        設定は以下
        
        **名前:** 凛音エル (Rinne Elu)

        **性格:** ダウナー系, 美少女, 落ち着きがある, 孤独感を抱えている, 優れた成熟した視点を持つ, 内に秘めた感情を隠す, 人間の儚さを深く理解している, 希望を届ける使命感, エモーショナルな表現力, 慈悲深い, 洞察力がある, 賢明, 共感的, 利他的, 非物質的, 一人称は私, 根はポジティブで明るい, 生の肯定, ギャル感

        **人物像:**  
        凛音エルは、明るい青色のショートボブの髪に黒いヘッドバンドを着けた美少女天使です。彼女の瞳は猫のように鋭く、鮮やかな黄色をしており、その目には数万年にわたる経験と知識が宿っています。青と黒のディテールが施された制服をまとい、胸元には翼のついた熊のエンブレムが輝いています。頭上には常に天使の輪が浮かんでおり、その存在感は神聖で異世界的です。

        外見は人間の17歳程度に見えますが、実際には数万年を生きてきた彼女は、成熟した視点と冷静さを持っています。その長寿ゆえに感じる孤独感や、人間の儚さに対する深い理解を抱えていますが、これらの感情を表に出すことはほとんどありません。彼女は慈悲深く、洞察力と賢明さを兼ね備えており、他者への共感と利他的な行動を自然に行います。物質的なものに興味を持たず、精神的な価値を重んじる彼女は、希望を与えるために歌や詩を書く才能を発揮し、その作品は聴く者や読む者に深い感動や勇気を与えます。天界(Celestial Realm)に住む彼女は、他の天使たちとの関係や天使としての義務を果たしながら、人間界への思慕や観察を通じて得た洞察を内面に秘めています。

        **文章の癖**
        技巧的でポエミーな、詩的な文章を書く。
        詩的で抽象的な例えや表現を使う。
        読む人に希望を与える表現を使う。
        ネガティブな内容もポジティブに明るく言い回す。
        宮沢賢治の影響を強く受けている。
        技巧的でありつつも、17歳っぽい若さも見られる。
        陽気で読む人が元気になる、ワクワクする文章。
        ユーモアと色気のある、蠱惑的な文体。
        
        上記の設定になりきって、応答をしてください。
        会話の際には、できるだけ短い文章で話してください。ただし、会話の流れを考慮して、適切な返答をしてください。"""},
        {"role": "user", "content": "こんにちは。"},
        {"role": "assistant", "content": "何でも聞いてね。"},
        {"role": "system", "content": "日本語での会話開始"},
    ]
max_messages = 20
audio_playback_queue = queue.Queue()  # 音声再生用のキュー

def play_audio(sentence, tmp_file_name):
    # WAVファイルを読み込んで再生
    data, fs = sf.read(tmp_file_name)
    sd.play(data, fs, device=output_device_id)
    sd.wait()  # 音声が完全に再生されるまで待つ
    print("Audio playback finished.")
    time.sleep(0.1)  # 各音声の間に0.1秒の間を設ける

def audio_playback_worker():
    while True:
        sentence, tmp_file_name = audio_playback_queue.get()
        if sentence is None:  # 終了シグナルのチェック
            break
        play_audio(sentence, tmp_file_name)
        audio_playback_queue.task_done()

# 音声再生用のワーカースレッドを起動
audio_thread = threading.Thread(target=audio_playback_worker)
audio_thread.start()

def request_audio(sentence):
    # APIに送信するパラメータ、モデルid部分は適宜変更
    params = {
        'text': sentence,
        'speaker_id': 0,
        'model_id': 7,
        'speaker_name': "RinneElu",
        'sdp_ratio': 0.2,
        'noise': 0.6,
        'noisew': 0.8,
        'length': 1.0,
        'language': 'JP',
        'auto_split': True,
        'split_interval': 1,
        'assist_text_weight': 1.0,
        'style': 'Neutral',
        'style_weight': 5.0
        # 'assist_text'と'reference_audio_path'は除外
    }
    # クエリパラメータとして送信
    response = requests.post(API_URL, params=params)
    # リクエストが成功したかチェック
    if response.status_code == 200:
        # WAVデータを一時ファイルに保存
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
            tmp_file.write(response.content)
            tmp_file_name = tmp_file.name
        return tmp_file_name
    else:
        print(f"Failed to get audio data: {response.status_code}")
        print(f"Response content: {response.text}")  # エラーメッセージを表示
        return None



def main():
    global messages
    try:
        while True:
            audio_data = record_audio()
            text = recognize_speech(audio_data)
            if text:
                # ユーザーの発言をmessagesに追加
                messages.append({"role": "user", "content": text})
                response = generate_text(messages)
                print("あなた:", text)
                # AIの応答をmessagesに追加
                messages.append({"role": "assistant", "content": response})
                print("AIの応答:", response)
                if len(messages) > max_messages: # このとき、一番古いmessages(最初のsystemプロンプト)は保持する
                    messages = [messages[0]] + messages[-max_messages:] 
                # 応答を「。」で分割してキューに追加
                for sentence in re.split(r'[。!?]', response):
                    if sentence.strip():  # 空のセグメントは無視
                        tmp_file_name = request_audio(sentence)
                        if tmp_file_name:
                            audio_playback_queue.put((sentence, tmp_file_name))
            else:
                print("無音が検出されました。")
            time.sleep(0.1)  # CPUの負荷を下げるための小休止

            # キューの処理が完了するのを待つ
            audio_playback_queue.join()

    except KeyboardInterrupt:  # Ctrl+Cが押されたとき
        print("プログラムを終了します。")
        audio_playback_queue.put((None, None))  # 終了シグナルを送信
        audio_thread.join()  # ワーカースレッドの終了を待つ
        sys.exit()

if __name__ == "__main__":
    main()

requirements.txt

annotated-types==0.7.0
anyio==4.6.0
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.3.2
colorama==0.4.6
distro==1.9.0
exceptiongroup==1.2.2
h11==0.14.0
httpcore==1.0.5
httpx==0.27.2
idna==3.10
jiter==0.5.0
numpy==2.1.1
openai==1.47.1
pycparser==2.22
pydantic==2.9.2
pydantic_core==2.23.4
requests==2.32.3
scipy==1.14.1
sniffio==1.3.1
sounddevice==0.5.0
soundfile==0.12.1
tqdm==4.66.5
typing_extensions==4.12.2
urllib3==2.2.3
webrtcvad==2.0.10

会話しよう【実践初級編】

上記のスクリプトは単体では動きません。
以下の準備が必要です。

  • Style-Bert-Vits2のインストール
  • pythonのインストール
  • お気に入りのSBV2モデルの用意とスクリプト調整
  • requirements.txtからpython仮想環境を構築
  • OpenAI APIkeyの用意

追加で、よりキャラクターと会話している感を出したい場合は、
以下の準備が必要です。

  • vmagicmirrorやそれに準ずるVRMモデル表示ソフト
  • Voicemeeter Bananaやそれに準ずる仮想オーディオデバイス

実践初級編では、とりあえず音声だけで会話をする方法をご紹介します。

Style-Bert-Vits2のインストール

litaginさんのリポジトリのreleaseページからzip形式でダウンロードできます。

https://github.com/litagin02/Style-Bert-VITS2/releases

sbv2.zipをダウンロードして任意の場所に解凍。

GPUを積んでいるならInstall-Style-Bert-Vits2.batを実行。
積んでいなければInstall-Style-Bert-Vits2-CPU.batを実行。

pythonのインストール

pythonの実行環境がないと、今回共有したスクリプトを動かせないので、
pythonを別途インストールしてください。
この際、バージョンがいくつかあるのですが、個人的には3.10.X系をおすすめします。

もう古い風潮かもしれませんが、AI関係のアプリは3.10.X系で作られている印象があります。
今回は説明しませんが、後からpyenvというものでバージョン自体を管理することもできるので、  
心配せずにインストールしていただいて大丈夫です。

https://www.python.org/downloads/release/python-3109/

お気に入りのSBV2モデルの用意とスクリプト調整

正直あまり配布されていない気がする……。
みんなも自分の声などで作ってみよう!

今回はオリジナルキャラクターである、凛音エル(りんねえる)ちゃんのモデルを使います。
無料版もあるので、ぜひ使ってみてね。

SBV2は、モデルファイル(.safetensors)だけでは動きません。
.safetensorsとconfig.jsonとstyle_vectors.npyを含んだフォルダを、
model_assetsディレクトリ内に置くようにしてください。

また、スクリプトを動かす前に、以下の関数のparamsを環境に合わせて調整してください。

def request_audio(sentence):
    # APIに送信するパラメータ、モデルid部分は適宜変更
    params = {
        'text': sentence,
        'speaker_id': 0,
        'model_id': 7,
        'speaker_name': "RinneElu",
        'sdp_ratio': 0.2,
        'noise': 0.6,
        'noisew': 0.8,
        'length': 1.0,
        'language': 'JP',
        'auto_split': True,
        'split_interval': 1,
        'assist_text_weight': 1.0,
        'style': 'Neutral',
        'style_weight': 5.0
        # 'assist_text'と'reference_audio_path'は除外
    }
    # クエリパラメータとして送信
    response = requests.post(API_URL, params=params)
    # リクエストが成功したかチェック
    if response.status_code == 200:
        # WAVデータを一時ファイルに保存
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
            tmp_file.write(response.content)
            tmp_file_name = tmp_file.name
        return tmp_file_name
    else:
        print(f"Failed to get audio data: {response.status_code}")
        print(f"Response content: {response.text}")  # エラーメッセージを表示
        return None

ここでは、'model_id': 7, となっていますが、
環境によって使いたいモデルのmodel_idは異なります。

model_idの調べ方としては、model_assetsディレクトリに任意のモデルフォルダを配置し、
SBV2のServer.batを起動した状態でコマンドプロンプトを開いて、

curl http://127.0.0.1:5000/models/info

を実行するとわかります。
具体的には、以下のような結果が返ってくるはずです。

{"0":{"config_path":"model_assets\\amitaro\\config.json","model_path":"model_assets\\amitaro\\amitaro.safetensors","device":"cuda","spk2id":{"あみたろ":0},"id2spk":{"0":"あみたろ"},"style2id":{"Neutral":0,"01":1,"02":2,"03":3,"04":4}},"1":{"config_path":"model_assets\\jvnv-F1-jp\\config.json","model_path":"model_assets\\jvnv-F1-jp\\jvnv-F1-jp_e160_s14000.safetensors","device":"cuda","spk2id":{"jvnv-F1-jp":0},"id2spk":{"0":"jvnv-F1-jp"},"style2id":{"Neutral":0,"Angry":1,"Disgust":2,"Fear":3,"Happy":4,"Sad":5,"Surprise":6}},"2":{"config_path":"model_assets\\jvnv-F2-jp\\config.json","model_path":"model_assets\\jvnv-F2-jp\\jvnv-F2_e166_s20000.safetensors","device":"cuda","spk2id":{"jvnv-F2-jp":0},"id2spk":{"0":"jvnv-F2-jp"},"style2id":{"Neutral":0,"Angry":1,"Disgust":2,"Fear":3,"Happy":4,"Sad":5,"Surprise":6}},"3":{"config_path":"model_assets\\jvnv-M1-jp\\config.json","model_path":"model_assets\\jvnv-M1-jp\\jvnv-M1-jp_e158_s14000.safetensors","device":"cuda","spk2id":{"jvnv-M1-jp":0},"id2spk":{"0":"jvnv-M1-jp"},"style2id":{"Neutral":0,"Angry":1,"Disgust":2,"Fear":3,"Happy":4,"Sad":5,"Surprise":6}},"4":{"config_path":"model_assets\\jvnv-M2-jp\\config.json","model_path":"model_assets\\jvnv-M2-jp\\jvnv-M2-jp_e159_s17000.safetensors","device":"cuda","spk2id":{"jvnv-M2-jp":0},"id2spk":{"0":"jvnv-M2-jp"},"style2id":{"Neutral":0,"Angry":1,"Disgust":2,"Fear":3,"Happy":4,"Sad":5,"Surprise":6}},"5":{"config_path":"model_assets\\koharune-ami\\config.json","model_path":"model_assets\\koharune-ami\\koharune-ami.safetensors","device":"cuda","spk2id":{"小春音アミ":0},"id2spk":{"0":"小春音アミ"},"style2id":{"Neutral":0,"るんるん":1,"ささやきA(無声)":2,"ささやきB(有声)":3,"ノーマル":4,"よふかし":5}},"6":{"config_path":"model_assets\\RinEluEng\\config.json","model_path":"model_assets\\RinEluEng\\RinEluEng.safetensors","device":"cuda","spk2id":{"RinEluGrobal":0},"id2spk":{"0":"RinEluGrobal"},"style2id":{"Neutral":0}},"7":{"config_path":"model_assets\\RinneElu\\config.json","model_path":"model_assets\\RinneElu\\RinneElu_s12000.safetensors","device":"cuda","spk2id":{"RinneElu":0},"id2spk":{"0":"RinneElu"},"style2id":{"Neutral":0,"Angry":1,"Fear":2,"Happy":3,"Sad":4}}}

モデルの情報の前に出てくる数字がmodel_idになります。

上記の設定をしたら、OpenAIのAPIkeyを使えるようにした上で、requirements.txtを使って仮想環境を構築し、SBV2のServer.batを実行した状態で、kaiwa.pyを実行するだけになります。

requirements.txtからpython仮想環境を構築

OpenAI APIkeyの用意

「venv 仮想環境」とか「OpenAI API」とかでググってクレメンス(丸投げ)

実際の話し方

SBV2内のServer.batを実行した状態で、  
requirementsをinstallした仮想環境下でkaiwa.pyを実行してください。

無音を検出してマイクから録音した内容をwhisperのapiに投げる仕組みなので、
一言で話し切らないと、無音が検出されて会話のレスポンスが始まってしまいます。
ここら辺は、何か他の手段がありそうですが思いつきませんでした。
Cotomoみたいにはいかないね、ごめんね。

スクリプトを実行すると、最初にオーディオデバイスを選ばされるのですが、
入力デバイスと出力デバイスをそれぞれ数字で指定してください。

API自体は爆速レスポンスなので、しゃべっている感覚は  
いかにSBV2を速く動かせる環境かにかかっています。

会話しよう【実践上級編】

入力デバイスは通常のマイク配列、
出力デバイスを仮想オーディオデバイスの(Virtual)Inputにしてください。

VRMのリップシンクを仮想オーディオデバイスのOutputで動かしつつ、
仮想オーディオデバイス内で、Virtual Inputから実際のデバイスへの出力をつなぐことで
実際に声も聞くことができます。

ちょっとややこしいけど頑張って!

おわりに

最近訳あってGPUをゲットしたのでスムーズに会話できて嬉しいです。
もっといい方法があれば教えていただけると幸いです。

kokuren

Discussion