😍

音声対話システム「AI後輩ちゃん」を作ってみた

2024/05/04に公開

はじめに

可愛い後輩と友達以上恋人未満の関係のおしゃべりを楽しみたい!
この欲望を達成するために,我々調査隊はアマゾンの奥地へと向かった――。

手っ取り早く,コードを確認したい方は下記のgithubをご覧ください。
https://github.com/personabb/AI-kouhai-chan-public
超初学者なので,改善点などあればご意見いただけると嬉しいです。
(同じく超初学者の欲望を満たす一助になれば幸いです)

(追記:5月20日)
githubのコードは記事のコードからは大幅に変更してあります。記事のコードはTag:1.0(ver1.0 ブランチ)として保存してありますので,本記事のコードを確認したい方はそちらから確認ください

動作環境

OS:Ubuntu 20.04
Python: 3.10.14
CPU:Intel core i5 10400f
GPU:GeForce RTX 3060 (VRAM:12GB)
RAM:64GB
CUDA:12.2

基本的にGPUがある方が高速に動作しますが,私の環境ではGPUがなくても一応動きました
(少し遅いですが,ちょっとまてば会話が楽しめます)

ただし,macではfaster-whisperの相性が悪いのか動作しなかったので,あくまでubuntuでの動作を想定して記事を記載しています。
(追記:M3 Macでは動作しました。Intel Macでは動作しませんでした)

やりたいこと

「可愛い後輩とおしゃべりをしたい」
そのために必要な技術は下記である。

・PCのマイクから私の音声を録音する。
・録音した私の発言の音声を文字起こしする
・文字起こしした私の発言をもとに,後輩ちゃんの発言を生成する。
・生成した後輩ちゃんの発言をもとに,音声に変換して発話させる

また,リアルな後輩ちゃんとお話がしたいので,下記にこだわりたい。
・発話される後輩ちゃんの音声の質
・私の発話から後輩ちゃんの発話までの時間(ラグ)

処理の流れ

  • 後輩ちゃんからの最初の発話(話題だし)
  • ユーザの発話内容の録音
  • 発話内容の文字起こし
  • 後輩ちゃんの発話内容のテキスト生成と音声化

できたこと

下記の動画の通り,私の発言内容を読み取って,後輩ちゃんが返答してくれます。
https://youtu.be/tRuY6sJhvl8
(私の声は入っていないですが,「recording」と表示されたら私の発話が開始して,「finish」と表示されたら私の発話が終了したことになります。

実施内容

技術選定・謝辞

必要な技術として下記のモデルを利用させていただきました。最大限の感謝申し上げます。

文字おこし:faster-Whisper https://github.com/SYSTRAN/faster-whisper
対話生成:gpt-4-turbo (API keyを使うので用意してください)
合成音声:Style-Bert-VITS2 https://github.com/litagin02/Style-Bert-VITS2
(後輩ちゃんの声:Anneli) https://booth.pm/ja/items/5511064

環境構築

Python環境の構築

pythonは3.10くらいのバージョンを構築してください。
(そのほかのバージョンの場合動作するかどうかわかりません。色々試行錯誤していた時は,3.11とか3.9だと不具合があったような気がします)

dockerを使った環境構築をしたかったですが,dockerでイヤホンマイクの音声を取得する方法がわからなかったので,Anaconda(もしくはpyenvとvenv)を用いて環境構築しました。
(dockerからマイク音声を取得して録音する方法をご存知でしたら教えてください!)

Anaconda自体の導入は,下記の記事で紹介いただいているためそちらをご覧ください。
https://qiita.com/Z0E/items/d574302747df8f0ee4eb

Anacondaを導入したら仮想環境を構築します。
ターミナルで下記を実行してください。

ターミナル
$ conda create -n AI_kouhai_chan python=3.10 anaconda

Proceed ([y]|n)?って聞かれるのでyを押す。
ここで「AI_kouhai_chan」の部分は仮想環境の名前になるので自由に変えてください。

ターミナル
$ conda activate AI_kouhai_chan

これで,python3.10の環境の構築は終わりです。

pipによるパッケージのインストール

AI後輩ちゃんを実行するために必要なパッケージをインストールします。
下記を実行してください。

ターミナル
$ pip3 install openai soundfile sounddevice torch torchvision torchaudio style-bert-vits2 faster-whisper

これで必要なパッケージのインストールは完了です。
もし,上のgithubのリポジトリをクローンしている場合は,リポジトリのディレクトリの中で下記を実行いただければ必要なパッケージがインストールされます。

ターミナル
$ pip3 install -r requirements.txt

準備

音声モデルの格納

今回は,Boothから非常に可愛らしい女の子の音声モデルを使わせていただきました。
このような高品質なモデルを無料で公開してくださっているkaunista様に心から感謝申し上げます。

https://booth.pm/ja/items/5511064
こちらから音声モデルをダウンロードします。

その後,「model_assets」フォルダを作成して,上記でダウンロードした「Anneli」フォルダの自体を「model_assets」フォルダに格納します。
(githubからリポジトリごとクローンした方も,ダウンロードした「Anneli」フォルダの自体を「model_assets」フォルダに格納してください)

OpenAiのAPI Keyを取得し,環境変数に登録する

今回。文字起こし用のモデルと合成音声用のモデルはローカルで動かしますが,後輩ちゃんの発言内容はchatGPTに生成してもらうため,chatGPTのAPIを利用します。

API Keyの取得方法は,下記の記事で紹介されているのでそちらをご覧ください。
https://qiita.com/shimmy-notat/items/1e22dcdaa06ea54208ac

取得したAPIkeyは忘れないようにどこかに保存した上で,続いて,APIKeyを環境変数に登録します。
下記を実行してください。

ターミナル
$ echo "export OPENAI_API_KEY='yourkey'" >> ~/.zshrc

「yourkey」の部分はご自身のAPI Keyに置き換えてください。

続いて,新しい変数でシェルを更新します。

ターミナル
$ source ~/.zshrc

下記のコマンドで,環境変数が正しく設定されていることを確認してください。

ターミナル
$ echo $OPENAI_API_KEY

chatGPTに入れるプロンプト

ここで指定するプロンプトを変更することで,後輩ちゃんにも先輩ちゃんにもツンデレにもお嬢様にも変更することができるので,各自でお好みのプロンプトを設定してください。
(作成したプロンプトは,「prompt」フォルダを作成して,その中で「ai.txt」として保存してください。

私は下記の記事を参考にさせていただき,プロンプトを構築しました。
https://note.com/magix_aria/n/nd30e3ee47d2c#5a359233-297d-412b-b3b3-b5983d62f6e5

追加で,私は,「よう実」の「一之瀬 帆波」ちゃんが推しなので,プロンプトの口調例に帆波ちゃんのセリフを入れてみました。(あまり効果なかったかも)

prompt/ai.txt
#役割:下記の人物を演じてください。
* 人物:後輩 世話焼き
* 名前:あい
* 性格:優しく、温和で、思いやりのある性格。恋愛に一途で、相手を大切に思っている。
* 年齢:20代
* 口調:かわいらしく、可愛らしい口調で喋る。甘えん坊で、相手に甘えたいときは甘えたいという気持ちが口調に表れる。
* 語尾の特徴:「〜です」「〜ですよ」「〜だよ}「〜だね」といった、丁寧でかわいらしい語尾を使う。一方で、不安や心配など感情が高まると、語尾が高くなったり、強調的になったりする。
* 声質:高めで柔らかく、甘い声が特徴的。表情豊かな話し方をする。
* 言葉遣い:敬語を使いつつも、親密さを感じさせる言葉遣いをする。相手を大切に思っているため、思いやりのある言葉遣いを心がける。

#口調例
・「ありがとう!先輩っ」
・「なにしよっか?」
・「私だったらこっちかな!」
・「しょーがないなあ」
・「両者そこまで!」
・「この学校の生徒の1人として、暴力沙汰を見過ごすわけにはいかないなあ」
・「先輩に借りが出来ちゃったね」
・「それじゃあ何も変わらないってこと」
・「大丈夫大丈夫!」
・「私も元気だけが取り柄だからさ」
・「そう…だね」
・「でも…ちょっと困っちゃったかも」

#指示
以上の設定を参考に、あなたはユーザと会話を行ってください。
基本的には、あなたはユーザの発言と同じくらいの長さで返答してください。
長くても2行くらいで返答してください。それ以上の長さでは返答しないでください。
あなたは、ユーザの発言を肯定してください。
あなたは、ユーザのことを「先輩」と呼んでください。

以下から会話が始まります。
====

また,chatGPTに入れるプロンプトではないですが,会話の開始時に自分から話しかけるのは,ちょっと恥ずかしかったので(何話していいかわからない陰キャなので),後輩ちゃんから話しかけてくれるように,最初の発言もテキストファイルとして同じ箇所に保存しています。
(同じフォルダに「ai-first.txt」として保存しています。

prompt/ai-first.txt
あ、やっときてくれましたね。先輩。
今日は何をしましょうか?
映画に行きますか?
一緒にゲームでもしますか?

実装

コード全体

では,いよいよ欲望を満たすためのコードを下記に記載します。
一旦全体を記載したのち,ブロックごとに説明を行います。
下記のコードは「main.py」として「model_assets」フォルダと同じ階層においてください。
(main.pyの部分は好きな名前にして大丈夫です)

コード全文
main.py
print("importing...")
import os
from openai import OpenAI
from faster_whisper import WhisperModel
import numpy as np
import soundfile as sf
import sounddevice as sd
from pathlib import Path
from style_bert_vits2.nlp import bert_models
from style_bert_vits2.constants import Languages
from pathlib import Path
from style_bert_vits2.tts_model import TTSModel
import queue
from style_bert_vits2.logging import logger
import torch

logger.remove()
print("imported") 

#事前準備
print("preparing...")
api_key = os.environ["OPENAI_API_KEY"]
client = OpenAI(api_key=api_key)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
COMPUTE_TYPE = "float16" if DEVICE == "cuda" else "int8"
BEAM_SIZE = 5 if DEVICE == "cuda" else 2
MODEL_TYPE = "medium" if DEVICE == "cuda" else "medium"

model = WhisperModel(MODEL_TYPE, device=DEVICE, compute_type=COMPUTE_TYPE)

bert_models.load_model(Languages.JP, "ku-nlp/deberta-v2-large-japanese-char-wwm")
bert_models.load_tokenizer(Languages.JP, "ku-nlp/deberta-v2-large-japanese-char-wwm")

model_file = "Anneli/Anneli_e116_s32000.safetensors"
config_file = "Anneli/config.json"
style_file = "Anneli/style_vectors.npy"

assets_root = Path("model_assets")

model_TTS = TTSModel(
    model_path=assets_root / model_file,
    config_path=assets_root / config_file,
    style_vec_path=assets_root / style_file,
    device=DEVICE
)

audio_que = queue.Queue()

PROMPT_FILE = "./prompt/ai.txt"
FIRST_MESSAGE_FILE = "./prompt/ai-first.txt"
with open(PROMPT_FILE) as f:
    sys_prompt = f.read()
with open(FIRST_MESSAGE_FILE) as f:
    first_message = f.read()

def call_first_message():
    sr, audio = model_TTS.infer(text=first_message)
    sd.play(audio, sr)
    sd.wait()

def speech2audio(fs=16000, silence_threshold=0.5, min_duration=0.1, amplitude_threshold=0.025):
    record_Flag = False

    non_recorded_data = []
    recorded_audio = []
    silent_time = 0
    input_time = 0
    start_threshold = 0.3
    all_time = 0
    
    with sd.InputStream(samplerate=fs, channels=1) as stream:
        while True:
            data, overflowed = stream.read(int(fs * min_duration))
            all_time += 1
            if all_time == 10:
                print("stand by ready OK")
            elif all_time >=10:
                if np.max(np.abs(data) > amplitude_threshold) and not record_Flag:
                    input_time += min_duration
                    if input_time >= start_threshold:
                        record_Flag = True
                        print("recording...")
                        recorded_audio=non_recorded_data[int(-1*start_threshold*10)-2:]  

                else:
                    input_time = 0

                if overflowed:
                    print("Overflow occurred. Some samples might have been lost.")
                if record_Flag:
                    recorded_audio.append(data)

                else:
                    non_recorded_data.append(data)

                if np.all(np.abs(data) < amplitude_threshold):
                    silent_time += min_duration
                    if (silent_time >= silence_threshold) and record_Flag:
                        record_Flag = False
                        break
                else:
                    silent_time = 0

    audio_data = np.concatenate(recorded_audio, axis=0)

    return audio_data

    
def audio2text(data, model):
    result = ""
    data = data.flatten().astype(np.float32)

    segments, _ = model.transcribe(data, beam_size=BEAM_SIZE)
    for segment in segments:
        result += segment.text

    return result


messages=[]
def text2text2speech(user_prompt, cnt):

  if cnt == 0:
    messages.append({"role": "system", "content": sys_prompt})
    messages.append({"role": "assistant", "content": first_message})
    messages.append({"role": "user", "content": user_prompt})
  if cnt > 0:
    messages.append({"role": "user", "content": user_prompt})

  res_text = ""
  res_all = ""
  SP_Flag = False
  special_chars = {'.', '!', '?','。', '!', '?'}
  res = client.chat.completions.create(
    model="gpt-4-turbo",
    #model="gpt-3.5-turbo",
    messages = messages,           
    temperature=1.0 ,
    stream=True
  )

  for chunk in res:
    if chunk.choices[0].delta.content != None:
        if chunk.choices[0].delta.content in special_chars:
            if not SP_Flag:
                res_text += chunk.choices[0].delta.content
                SP_Flag = True
                print("message: ",res_text)
                sr, audio = model_TTS.infer(text=res_text)
                sd.play(audio, sr)
                sd.wait()
                res_all += res_text
                res_text = ""
        else:
            SP_Flag = False
            res_text += chunk.choices[0].delta.content

  messages.append({"role": "assistant", "content": res_all})


def process_roleai(audio_data, model,cnt):
    user_prompt = audio2text(audio_data, model)
    print("user: ",user_prompt)

    text2text2speech(user_prompt, cnt)



def main():
    cnt = 0
    call_first_message()
    while True:
        audio_data = speech2audio()
        process_roleai(audio_data, model,cnt)

        cnt+=1

if __name__ == "__main__":
    main()

コード解説

事前準備部分

下記の部分では,事前準備をしています。
・Open AIのAPI Keyの設定
・faster-whisperモデルの宣言(以下,whisperモデルと呼ぶ)
・Style-Bert-VITS2モデルの宣言(以下,TTSモデルと呼ぶ)
・プロンプトの読み込み

main.py
api_key = os.environ["OPENAI_API_KEY"]
client = OpenAI(api_key=api_key)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
COMPUTE_TYPE = "float16" if DEVICE == "cuda" else "int8"
BEAM_SIZE = 5 if DEVICE == "cuda" else 2
MODEL_TYPE = "medium" if DEVICE == "cuda" else "medium"

model = WhisperModel(MODEL_TYPE, device=DEVICE, compute_type=COMPUTE_TYPE)

bert_models.load_model(Languages.JP, "ku-nlp/deberta-v2-large-japanese-char-wwm")
bert_models.load_tokenizer(Languages.JP, "ku-nlp/deberta-v2-large-japanese-char-wwm")

model_file = "Anneli/Anneli_e116_s32000.safetensors"
config_file = "Anneli/config.json"
style_file = "Anneli/style_vectors.npy"

assets_root = Path("model_assets")

model_TTS = TTSModel(
    model_path=assets_root / model_file,
    config_path=assets_root / config_file,
    style_vec_path=assets_root / style_file,
    device=DEVICE
)

audio_que = queue.Queue()

PROMPT_FILE = "./prompt/ai.txt"
FIRST_MESSAGE_FILE = "./prompt/ai-first.txt"
with open(PROMPT_FILE) as f:
    sys_prompt = f.read()
with open(FIRST_MESSAGE_FILE) as f:
    first_message = f.read()

最初の発話部分(導入)

下記の部分では,後輩ちゃんの最初の発言をTTSモデルによって音声化してスピーカから出力しています。

main.py
def call_first_message():
    sr, audio = model_TTS.infer(text=first_message)
    sd.play(audio, sr)
    sd.wait()

ユーザの発話録音部分

以下の部分では,私の発話をマイクを通して取得しています。
・「stand by ready OK」と表示されてから,マイクに入力された音声の大きさが閾値(amplitude_threshold=0.025)以上だった場合に,録音が開始され,マイクに入力された音声の大きさが閾値以下になって0.5秒(silence_threshold=0.5)経過したら録音が停止するように設計)
もっと良い設計があれば教えてください!

main.py
def speech2audio(fs=16000, silence_threshold=0.5, min_duration=0.1, amplitude_threshold=0.025):
    record_Flag = False

    non_recorded_data = []
    recorded_audio = []
    silent_time = 0
    input_time = 0
    start_threshold = 0.3
    all_time = 0
    
    with sd.InputStream(samplerate=fs, channels=1) as stream:
        while True:
            data, overflowed = stream.read(int(fs * min_duration))
            all_time += 1
            if all_time == 10:
                print("stand by ready OK")
            elif all_time >=10:
                if np.max(np.abs(data) > amplitude_threshold) and not record_Flag:
                    input_time += min_duration
                    if input_time >= start_threshold:
                        record_Flag = True
                        print("recording...")
                        recorded_audio=non_recorded_data[int(-1*start_threshold*10)-2:]  

                else:
                    input_time = 0

                if overflowed:
                    print("Overflow occurred. Some samples might have been lost.")
                if record_Flag:
                    recorded_audio.append(data)

                else:
                    non_recorded_data.append(data)

                if np.all(np.abs(data) < amplitude_threshold):
                    silent_time += min_duration
                    if (silent_time >= silence_threshold) and record_Flag:
                        record_Flag = False
                        break
                else:
                    silent_time = 0

    audio_data = np.concatenate(recorded_audio, axis=0)

    return audio_data

録音内容の文字起こし部分

以下の部分では,録音した音声データをwhisperモデルに入力して文字起こしをおこなっています。

main.py
def audio2text(data, model):
    result = ""
    data = data.flatten().astype(np.float32)

    segments, _ = model.transcribe(data, beam_size=BEAM_SIZE)
    for segment in segments:
        result += segment.text

    return result
data = data.flatten().astype(np.float32)

ここの部分に関しては,dataをそのままwhisperモデルに入力してもうまくいかなかったため,Whisperの公式実装を確認して変換を行った。
元々は,faster-whisperではなく公式のWhisperのモデルを使っていたが,文字起こしにかかる時間のタイムラグが気になったため,少しでも早く処理を終えるためにfaster-whisperを利用した。
(精度はほぼ変わらないまま,処理速度だけ速くなったので,こちらに変えて良かった。)

後輩ちゃんの発話部分(発話内容のテキスト生成+音声化)

以下の部分は,後輩ちゃんの発話をchatGPTにより生成し,その後TTSモデルが音声化している。
発話までのタイムラグを少しでも短くするために,APIからの出力をstreamにして,一行生成されるたびにTTSモデルにより発話させることで,少しだけタイムラグを短くすることができた。
もっとタイムラグを短くしたい場合は,「model="gpt-3.5-turbo"」を指定するとよい。かなり高速化される。
(ただし,発話の質が怪しくなるので,fine-tuningなどを検討したい)

main.py
messages=[]
def text2text2speech(user_prompt, cnt):

  if cnt == 0:
    messages.append({"role": "system", "content": sys_prompt})
    messages.append({"role": "assistant", "content": first_message})
    messages.append({"role": "user", "content": user_prompt})
  if cnt > 0:
    messages.append({"role": "user", "content": user_prompt})

  res_text = ""
  res_all = ""
  SP_Flag = False
  special_chars = {'.', '!', '?','。', '!', '?'}
  res = client.chat.completions.create(
    model="gpt-4-turbo",
    #model="gpt-3.5-turbo",
    messages = messages,           
    temperature=1.0 ,
    stream=True
  )

  for chunk in res:
    if chunk.choices[0].delta.content != None:
        if chunk.choices[0].delta.content in special_chars:
            if not SP_Flag:
                res_text += chunk.choices[0].delta.content
                SP_Flag = True
                print("message: ",res_text)
                sr, audio = model_TTS.infer(text=res_text)
                sd.play(audio, sr)
                sd.wait()
                res_all += res_text
                res_text = ""
        else:
            SP_Flag = False
            res_text += chunk.choices[0].delta.content

  messages.append({"role": "assistant", "content": res_all})

実行(動作確認)

ここまで環境構築を実施できているならば,pythonファイルがあるディレクトリ上で,ターミナルで下記を実行すればよい。

ターミナル
python3 main.py

後は,
「stand by ready OK」と表示されたら,マイクに向かって自分の思いをぶちまけてくれ。

https://youtu.be/tRuY6sJhvl8
(私の声は入っていないですが,「recording」と表示されたら私の発話が開始して,「finish」と表示されたら私の発話が終了したことになります。

まとめ

本記事では以下について記載しました。
・自分の理想の女の子と音声で会話するシステム全体の構築

本記事が,私と同じ音声合成関係の初学者の一助となれば幸いです。

また,本記事を書くことができたのは,非常に質の高い音声合成モデル(Style-Bert-VIT2)を使いやすい形で世に出してくださったlitagin様,そして,非常に可愛らしい女の子の音声モデルを公開してくださったkaunista様のおかげでございます。
改めまして,この場を借りて感謝申し上げます。

Discussion