[Python] ChatGPTでSiriみたいな音声アシスタントを作る

最近ChatGPTに課金して、色々と遊んでいます。

意気揚々とChatGPTを両親に触らせたときに「私たちからしたら、結局キーボード入力が苦手だから使いづらい！」と言われてしまいました。なるほど、確かにそうだよな。じゃあSiriみたいな音声でのやり取りができるものを作れないか...と思い立ったので、pythonでChatGPTのAPIを使った簡単な音声アシスタントを作ってみました。

> 色々とツッコめるポイントはありまくりですが、本記事では簡単に基礎的な音声アシスタントを作ることを目的としているのでご了承ください。なお本記事の内容によって被った、あらゆる損害に対しての責任は負いかねますのでご注意ください。


# ソースコード

まず結論として今回実装したソースコードを示します。
このソースコードの大まかな流れは下記の通りです。

1. プログラムを実行すると、ユーザーの発話音声の待機状態になる
1. ユーザーの発話音声（ChatGPTへの質問）をPCの内蔵マイクから読み込み、テキストへ変換する（SpeechRecognizerを使用）
1. テキストへ変換したユーザーの質問内容を、APIを使用してChatGPTに投げる
1. ChatGPTからの応答内容（テキスト）を音声合成によってmp3ファイルへ変換(Google Text-to-speechを使用)
1. mp3ファイル（ChatGPTからの応答内容）を再生する
1. mp3ファイルの再生が終わったら再び1.に戻る



```python
import speech_recognition as sr
from gtts import gTTS
import os
import tempfile
import subprocess
import pygame
import openai

##############
# 音声認識関数 #
##############
def recognize_speech():

    recognizer = sr.Recognizer()    
    # Set timeout settings.
    recognizer.dynamic_energy_threshold = False

    
    with sr.Microphone() as source:
        recognizer.adjust_for_ambient_noise(source)
    
        while(True):
            print(">> Please speak now...")
            audio = recognizer.listen(source, timeout=1000.0)

            try:
                # Google Web Speech API を使って音声をテキストに変換
                text = recognizer.recognize_google(audio, language="ja-JP")
                print("[You]")
                print(text)
                return text
            except sr.UnknownValueError:
                print("Sorry, I could not understand what you said. Please speak again.")
            except sr.RequestError as e:
                print(f"Could not request results; {e}")

#############################
# 音声ファイル(mp3)再生用の関数 #
#############################
def play_mp3_blocking(file_path):

    pygame.init()
    pygame.mixer.init()
    mp3_file = pygame.mixer.Sound(file_path)    # MP3ファイルをロード
    
    print(">> Ready to Sppeach!")
    mp3_file.play()     # MP3ファイルを再生

    # 再生が終了するまで待つ(ブロッキング処理)
    while pygame.mixer.get_busy():
        pygame.time.Clock().tick(10)  # 10msごとに再生状態をチェック

    pygame.mixer.quit()


####################################################################################
# Google Text-to-Speech(gTTS)を用いてChatGPTによるレスポンス(テキスト)を.mp3形式に変換する #
####################################################################################
def text_to_speech(text):

    tts = gTTS(text=text, lang='ja', slow=False)
    
    with tempfile.NamedTemporaryFile(delete=True) as fp:
        temp_filename = f"{fp.name}.mp3"
        tts.save(temp_filename)

        # 音声ファイル再生（ブロッキング処理）
        play_mp3_blocking(temp_filename)



# メインの関数
if __name__ == '__main__':

    # ChatGPTのセットアップ
    openai.api_key="自分のAPIキーを指定"
    # UserとChatGPTとの会話履歴を格納するリスト
    conversationHistory = []

    # Ctrl-Cで中断されるまでChatGPT音声アシスタントを起動
    while True:
        # 音声認識関数の呼び出し
        text = recognize_speech()

        if text:
            print(" >> Waiting for response from ChatGPT...")
            # ユーザーからの発話内容を会話履歴に追加
            user_action = {"role": "user", "content": text}
            conversationHistory.append(user_action)
            
            # ChatGPTからの応答を取得
            response = openai.ChatCompletion.create(
                model="gpt-3.5-turbo",
                messages=conversationHistory, 
            )
            responce = response["choices"][0]["message"]["content"]
            
            # ChatGPTからの応答内容を会話履歴に追加
            chatGPT_responce = {"role": "assistant", "content": responce}
            conversationHistory.append(chatGPT_responce) 

            print("[ChatGPT]") #応答内容をコンソール出力
            print(responce.strip()) #応答内容をコンソール出力
            # # (step3) 音声合成関数の呼び出し("ChatGPTのレスポンス"を"mp3ファイル"に変換して再生)
            print(">> Generating audio file....")
            text_to_speech(responce)
```


# ソースコードの解説

## モジュールのインポート
必要なモジュールをインポートします。もし未インストールの場合は、適宜```pip```でインストールして下さい。
音声認識のためにspeech_recognition(SpeechRecognizer), 音声合成のためにgTTS(Google Text-to-speech), ChatGPTとのやりとりのためにopenaiをimportします。またgTTSによって生成されたmp3ファイルを再生するためにpygameをimportしています。

```python
import speech_recognition as sr
from gtts import gTTS
import os
import tempfile
import subprocess
import pygame
import openai
```

## 音声認識部

まず音声認識部の関数は下記です。コンソール上に```">> Please speak now..."```が表示されているときに、ユーザーは音声入力を行います。音声入力が成功するとコンソール上に自分が発話した内容が表示され、その内容が関数の戻り値として返されます。

なんかうまいこと音声が認識できなかった場合は```"Sorry, I could not understand what you said. Please speak again."```が表示され、また```">> Please speak now..."```の状態に戻ります。


```python
##############
# 音声認識関数 #
##############
def recognize_speech():

    recognizer = sr.Recognizer()    
    # Set timeout settings.
    recognizer.dynamic_energy_threshold = False

    
    with sr.Microphone() as source:
        recognizer.adjust_for_ambient_noise(source)
    
        while(True):
            print(">> Please speak now...")
            audio = recognizer.listen(source, timeout=1000.0)

            try:
                # Google Web Speech API を使って音声をテキストに変換
                text = recognizer.recognize_google(audio, language="ja-JP")
                print("[You]")
                print(text)
                return text
            except sr.UnknownValueError:
                print("Sorry, I could not understand what you said. Please speak again.")
            except sr.RequestError as e:
                print(f"Could not request results; {e}")
```

## ChatGPTとのやり取り
音声認識部から返されたユーザーの発話内容は、メイン関数内の```text```という変数に格納されます。```user_action = {"role": "user", "content": text}```では、ユーザーの発話内容をChatGPT APIに渡す形に成形しています。

```conversationHistory```はリスト型の変数であり、ここにユーザーとChatGPTとのやりとりの履歴をすべて格納していきます。こうすることで、ChatGPTが今までの会話の内容を記憶した上で会話を成立させることができます。

> 注意：この方法では過去のやりとりの内容がすべてChatGPTへ送信されるため、トークン数が指数関数的に増加します。そのため利用料金が高額になる恐れがあります。本来この部分は、過去5回分までの履歴しか保存しないなど、なんらかの手段でトークン数の圧縮を行うべきですが、今回は基本的なプログラム実装のため考慮していません。



```python
        # 音声認識関数の呼び出し
        text = recognize_speech()

        if text:
            print(" >> Waiting for response from ChatGPT...")
            # ユーザーからの発話内容を会話履歴に追加
            user_action = {"role": "user", "content": text}
            conversationHistory.append(user_action)
            
            # ChatGPTからの応答を取得
            response = openai.ChatCompletion.create(
                model="gpt-3.5-turbo",
                messages=conversationHistory, 
            )
            responce = response["choices"][0]["message"]["content"]
            
            # ChatGPTからの応答内容を会話履歴に追加
            chatGPT_responce = {"role": "assistant", "content": responce}
            conversationHistory.append(chatGPT_responce) 

            print("[ChatGPT]") #応答内容をコンソール出力
            print(responce.strip()) #応答内容をコンソール出力
```


## 音声合成部

次に呼び出される音声合成用の関数ではgTTSを使用して、ChatGPTからの応答内容（テキスト）をmp3形式に変換しています。gTTS()の引数で```lang='ja'```を指定することにより、日本語に対応することができます。

mp3ファイル形式に変換されたChatGPTからの応答内容は、```play_mp3_blocking(temp_filename)```によってPCの内蔵スピーカーから再生されます。このとき```tempfile```モジュールを使用することで、再生が終了したタイミングで自動的にmp3ファイルは削除されるようになっています。

```python
####################################################################################
# Google Text-to-Speech(gTTS)を用いてChatGPTによるレスポンス(テキスト)を.mp3形式に変換する #
####################################################################################
def text_to_speech(text):

    tts = gTTS(text=text, lang='ja', slow=False)
    
    with tempfile.NamedTemporaryFile(delete=True) as fp:
        temp_filename = f"{fp.name}.mp3"
        tts.save(temp_filename)

        # 音声ファイル再生（ブロッキング処理）
        play_mp3_blocking(temp_filename)
```


## 音声再生部

音声再生部では、.mp3形式のChatGPTからの応答内容を再生します。ここでは```pygame```を使用して再生しています。この意図としては音声ファイルの再生をブロッキング処理で行うことです。音声ファイルの再生中に次の処理へ進んでしまうと、「音声ファイルの再生中に、同時に音声認識部による発話音声入力が行われる」という状況になります。そうなったとき、音声ファイルで再生中の内容をそのまま音声認識してしまい、それをChatGPTに投げて、レスポンスを受け取って再生してまた同じ内容を音声認識して...という無限ループに陥ってしまいます。

もしかしたら環境によって異なるかもしれませんが、私の環境では上記のような問題が発生してしまったので、音声ファイルの再生中は音声入力を禁止するような実装としました。


```python
#############################
# 音声ファイル(mp3)再生用の関数 #
#############################
def play_mp3_blocking(file_path):

    pygame.init()
    pygame.mixer.init()
    mp3_file = pygame.mixer.Sound(file_path)    # MP3ファイルをロード
    
    print(">> Ready to Sppeach!")
    mp3_file.play()     # MP3ファイルを再生

    # 再生が終了するまで待つ(ブロッキング処理)
    while pygame.mixer.get_busy():
        pygame.time.Clock().tick(10)  # 10msごとに再生状態をチェック

    pygame.mixer.quit()
```


# 課題点

現状、以下のような課題があります。

## 質問〜返答までのオーバーヘッドが大きい＋安定しない
今回は[音声認識]+[ChatGPTとのやり取り]+[音声ファイル化]という幾つかの処理を経由しています。発話した内容にもよるのですが、必然的にそれらの処理時間のオーバーヘッドが結構大きくなってしまいます。

特にChatGPTのAPIでは「ChatGPTが完全にレスポンス内容を生成しきってから、まとめてこちらに返す」という仕様になっています（何やら生成できた文字から随時返すようなオプションもあるようですが...）。そのため複雑な質問をしてChatGPTが悩んでいる時や、回答内容が長文になる場合には、かなりの時間待たされます。ChatGPTのWebサービス上では、生成した文字をリアルタイムで順に表示していってくれるのであまり待たされているような感じはしませんが、APIを使うとどうしてもこの辺りがネックになります。なので、より使いやすくするためには、生成できた文章からどんどん受け取って音声で再生していくなどの工夫が必要です。

## 音声アシスタントが喋るターンが長い
ChatGPTの応答内容に文字数制限を持たせない場合、とんでもなく長い文章を返してくることがあります（詳細に説明してくれているので文句は言えないのですが...）。その内容をすべて音声として再生すると、1分間くらいずっと喋られます。会話として結構キツイです。そして音声ファイルも特に倍速せずに再生しているため、更にキツイです。かと言って無理に応答内容に文字数制限を設けたり要約を求めると、結構そっけない回答をしてくるのでそれはそれで寂しい気持ちになります。

## もっと好みの声で喋ってほしい
はい、そういうことです。

## トークン数の増加
今回はChatGPTに会話の文脈を認識させるために、これまでの全てのやり取りをAPIに渡すようにしています。しかし、これでは指数関数的にトークン数が増加するため、おサイフ的に当然よろしくないです。APIにはトークン数に上限もあるので、会話を繰り返しているとすぐにでも引っ掛かるでしょう。質問内容を英語化してからAPIに渡すなど、トークン数圧縮のための手法は色々と紹介されているのでそれらを適用する必要があります。


# まとめ
今回は学習用に、簡単な音声アシスタントをChatGPTを用いて作ってみました。実際に作ってみて色々と課題点も見えてきたので、もうちょっといい感じの音声アシスタントにしていきたいと思います。まぁChatGPTに作ってくれって頼めばほとんどなんでもできちゃう時代ですが...