Streamlitで音声入力もできるチャットアプリを作ってみた（〜ChatGPT下位互換編〜）

# モチベーション
本アプリを作ってみたのは半年近く前だったのですが、せっかくなのでアドベントカレンダーの機会に記事として整理することにしました。

Streamlit自体は、2024年2月に海外の会社との共同ハッカソンに参加した際、同じチームのベトナム人エンジニアがアプリ実装にStreamlitを使っていたことから存在を知りました。
【関連記事】

https://lt-s.jp/news/pressrelease/2024-04-19

https://www.nikkei.com/nkd/disclosure/tdnr/20240417572255/ 


本記事は、StreamlitがPythonで簡単にフロント実装できることが魅力だったので勉強してみることにした時に題材として作ったアプリを紹介しています。

当時は以下のことを思って作ってみました。
* ChatGPTを使っていくうちに、「自分の相談事項を文字として打ち込むのが面倒」「上司に相談するみたいに、会話ベースで壁打ちしたい」と、自然と思うようになった
* すでにChatGPTでは音声入力にも対応しているが、それを自分でも実装できたら楽しそう！
* 案件や営業のとき、資料や概念だけで語るより、Streamlitなどで手元で簡単に動かせるモックを作っておけば、お客様も早めに詳しいイメージを持ってもらいやすそう！

# 前提
本記事では以下を前提とさせてください。
* streamlitとは何ぞや？あたりの詳細な説明は割愛します
（「`streamlit run`して起動」と言われたらなんのことか分かるくらいの知識があればOK）
* OpenAIのAPIキー取得については、他記事様などを参考にして取得しておいてください

# まずは完成イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/1a15a1f2-fed6-e0a3-f7b3-610018904302.png)

今回はChatGPTのAPIを使うことを想定して、ユーザーにOpenAI APIキーを左ペインの欄に入力してもらうようにします。チャット・音声を入力したときには、ここに入力されたAPIキーを一緒に送信します。

入力は、よくあるチャット入力のほか、マイクのアイコンを押すと音声入力できるようになっています。

左ペインにある「返答のストリーム表示」は、単にチャットの返答の見せ方の設定で、「あり」にすると、ChatGPTっぽく返答の文字が１文字ずつ流れるように表示され、「なし」にすると返答がポンと一発で表示されます。
これは単にStreamlitの練習と、「それっぽさ」を出せるようにしたかっただけです。笑

また本記事で使用しているChatGPTのエンジンは、当時（確か）無料で使えたChatGPT-3.5-turbo、音声認識のエンジンはWhisperとしています。

# 完成コード
細かな説明は後にして、「とにかく動くコードくれや！」という人は、以下をコピペし、`streamlit run chat.py（あるいはお好きなファイル名）`をコマンドプロンプトなりターミナルなりで起動すればOKです。
```python:chat.py
import streamlit as st
from audio_recorder_streamlit import audio_recorder
from tempfile import NamedTemporaryFile
from openai import OpenAI as openai
import time

def main():

    # サイドバーの表示
    openai_api_key, message_stream_setting = sidebar()

    # タイトルの表示
    st.title('🎙️💬 Audio-Chat')
    st.markdown("---")

    # マイク入力
    audio_bytes = audio_recorder(
        text="音声で入力したい場合はこちらをクリック！",
        pause_threshold=30
    )
    st.markdown("---")

    # 初期表示時のメッセージ
    if "messages" not in st.session_state:
        st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you?"}]

    # チャット履歴の表示
    for msg in st.session_state.messages:
        st.chat_message(msg["role"]).write(msg["content"])


    # チャットボックスの表示＆プロンプト入力時のユーザー・AIチャット表示追加
    if prompt := st.chat_input():
        if not openai_api_key:
            st.info("Please add your OpenAI API key to continue.")
            st.stop()

        st.session_state["messages"].append({"role": "user", "content": prompt})
        st.chat_message("user", avatar=None).write(prompt)
        
        # AIの返答
        reply = get_reply_from_gpt(openai_api_key, st.session_state.messages)
        st.session_state.messages.append({"role": "assistant", "content": reply})
        with st.chat_message("assistant"):
            writing_reply(reply, message_stream_setting)
            
    # 音声入力時の処理
    if audio_bytes:
        # 音声入力のテキスト変換
        transcript = transcribe_audio_to_text(openai_api_key, audio_bytes)
        st.session_state["messages"].append({"role": "user", "content": transcript})
        st.chat_message("user").write(transcript)

        # AIの返答
        reply = get_reply_from_gpt(openai_api_key, st.session_state.messages)
        st.session_state.messages.append({"role": "assistant", "content": reply})
        with st.chat_message("assistant"):
            writing_reply(reply, message_stream_setting)

def writing_reply(text, message_stream_setting):
    """
    AIからの返信を、一発でポンと表示するか、ストリームっぽく表示するか
    """
    if message_stream_setting == "なし":
        st.write(text)
    else:
        message_placeholder = st.empty() # 一時的なプレースホルダーを作成
        assistant_message = ""
        for chunk in text:
            assistant_message += chunk
            message_placeholder.write(assistant_message + "__") # カーソルのようなものとともにストリーム表示
            time.sleep(0.02)
        message_placeholder.write(assistant_message) # 最終的なメッセージを表示


def sidebar():
    """
    サイドバー
    """
    with st.sidebar:
        # OpenAI API Keyの入力
        openai_api_key = st.text_input("OpenAI API Key", key="chatbot_api_key", type="password")
        "[Get an OpenAI API key](https://platform.openai.com/account/api-keys)"

        st.markdown("---")

        # メッセージストリームの表示設定
        message_stream_setting = st.radio(
            "返答のストリーム表示",
            ["なし", "あり"]
        )
        return openai_api_key, message_stream_setting

def get_reply_from_gpt(openai_api_key:str, all_messages:dict):
    """
    今回はOpenAIのGPT-3.5-turboを使って、チャットの返答テキストを取得
    """
    client = openai(api_key=openai_api_key)
    response = client.chat.completions.create(model="gpt-3.5-turbo", messages=all_messages)
    reply = response.choices[0].message.content

    return reply

def transcribe_audio_to_text(openai_api_key, audio_bytes):
    """
    音声入力をWhisperでテキストに変換
    """
    client = openai(api_key=openai_api_key)
    with NamedTemporaryFile(delete=True, suffix=".wav") as temp_file:
        temp_file.write(audio_bytes)
        temp_file.flush()
        with open(temp_file.name, "rb") as audio_file:
            transcription = client.audio.transcriptions.create(
            model="whisper-1", 
            file=audio_file
            )
    return transcription.text

if __name__ == '__main__':
    main()

```

ここからは、チャット部分の実装、音声入力の実装について解説します。

# チャット部分の実装について説明
### チャット入力UI
チャット入力のUIは、`st.chat_input()`で簡単に実装できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/6619bce6-077d-df3e-4016-47a110b9468a.png)

チャットを入力し、送信ボタンを押すと、この`st.chat_input()`から値が返ってくるので、以下のif文スコープにおいて、変数`prompt`にその入力チャットを受け取りつつ、ChatGPTへの送信・返答を受け取る処理を行っていきます。（『チャットの記録・表示』に説明続く）
```python
# チャットボックスの表示＆プロンプト入力時のユーザー・AIチャット表示追加
if prompt := st.chat_input():
    ...
```

### チャットの記録・表示
chatGPTへのテキストの投げる前に、チャット履歴をどのように記録・表示していくかを先に説明します。

チャット内容の記録は、st.session_stateの"messages"にリストで登録しています。
各要素は、"role"（発話する主語）と"content"（発話の内容）からなる辞書としました。

以下の例では、"role"に「assistant」と「user」の２人が登場します。
`st.chat_message`は、チャットの見た目で表示してくれるライブラリで、roleについてはプリセットで用意されている値を渡すと、アイコン表示を割り当ててくれます。
assistantを渡すとロボットのアイコン、userを渡すと人間のアイコンで、チャット風に表示してくれるので、記録した全ての会話を順番に表示すると画像のようになります。
```python:チャット内容の記憶と表示
# こんな感じで、これまでの全ての会話を記録していく
st.session_state["messages"] = [
    {"role": "assistant", "content": "どうしましたか？"}, 
    {"role": "user",      "content": "君ってすごいね"}, 
    {"role": "assistant", "content": "いやぁ、ありがとうございます"}, 
]

# st.chat_messageを使って、全ての会話を表示する
for msg in st.session_state.messages:
    st.chat_message(msg["role"]).write(msg["content"])
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/70c8812d-5d33-a0ac-5942-d08a42173353.png)

今回は、チャット入力（あるいは後述する音声入力）があるたびに、自作関数の`get_reply_from_gpt`で、これまでの会話全てである`st.session_state["messages"]`を引数`all_messages`として渡し、そのままChatGPTに投げることで、回答を受け取っています。
（後述しますが、音声入力の場合も、音声をwhisperでテキスト化した上で、チャットと全く同様に`st.session_state["messages"]`に追加してChatGPTに投げているだけです）
```python
def get_reply_from_gpt(openai_api_key:str, all_messages:dict):
    """
    今回はOpenAIのGPT-3.5-turboを使って、チャットの返答テキストを取得
    """
    client = openai(api_key=openai_api_key)
    response = client.chat.completions.create(model="gpt-3.5-turbo", messages=all_messages)
    reply = response.choices[0].message.content

    return reply

```

ちなみにですが、会話をまだ入力していない初期状態では、以下のコード箇所で、最初に本アプリを起動した時 ＝ まだst.session_stateに会話の内容である"messages"を定義していない時には、「How can I help you?」をデフォルト表示するようにしています。
```python
# 初期表示時のメッセージ
if "messages" not in st.session_state:
    st.session_state["messages"] = [{"role": "assistant", "content": "How can I help you?"}]
```

# 音声入力の実装についての説明
音声入力は、以下の処理を辿っています。
1. ライブラリ「audio_recorder_streamlit」のaudio_recorderで、マイクから音声ファイルを取得
1. 取得した音声ファイルを、OpenAIのwhisperに渡してテキスト化してもらう
1. チャット入力した時と同じく、`st.session_state["messages"]`に会話を追加してChatGPTに投げる

音声ファイルを受け取る部分は非常に簡単です。
```python
from audio_recorder_streamlit import audio_recorder

###（中略）###

# マイク入力
audio_bytes = audio_recorder(
    text="音声で入力したい場合はこちらをクリック！",
    pause_threshold=30
)
```
ここで受け取った音声ファイルを自作関数`transcribe_audio_to_text`でwhisperに投げ、テキスト化したものを返してもらえば、あとはテキスト入力したのと同じです。
```python
def transcribe_audio_to_text(openai_api_key, audio_bytes):
    """
    音声入力をWhisperでテキストに変換
    """
    client = openai(api_key=openai_api_key)
    with NamedTemporaryFile(delete=True, suffix=".wav") as temp_file:
        temp_file.write(audio_bytes)
        temp_file.flush()
        with open(temp_file.name, "rb") as audio_file:
            transcription = client.audio.transcriptions.create(
            model="whisper-1", 
            file=audio_file
            )
    return transcription.text
```
こうすることで、マイクアイコンを押し、何かを喋って、再びマイクアイコンを押せば、自動で音声→テキスト化→ChatGPTへ送信→回答表示の処理が行われ、チャットに表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/d70dc917-76fc-9702-e807-33dacac97ed0.png)

# 最後に
今では、画像入力などマルチモーダルな入力が拡がってきました。
たった数年前まではLLMや生成系AIがここまで急速に発展するとは思っていませんでしたが、そんな当時思いもよらなかったことが最近では簡単に実装できるようになってきていて面白い限りです。

# 参考
以下記事・サイト様を参考に作成しました。
* [llm-examples.streamlit.app（Streamlitのチャットbotのサンプルアプリ）](https://llm-examples.streamlit.app/)
* [Streamlit x Whisperで音声入力機能を実装](https://note.com/astropomeai/n/n0529cc860862)
* [StreamlitでChatGPTのStreamを表示する](https://qiita.com/suzuki_sh/items/64d84c417cba43cd6351)