Web Speech API Speech Recognitionで、Chat-GPTとVOICEVOXを通して音声で会話する

# はじめに
初投稿です。故に至らぬところ多数あると思います・・・
普段はインフラしか触っておらずpythonは少しは書けるので何か作ってみたいと思っていた際、ChatGPTが出てきたのでトライしてみることにしました。
同じようなことをされてる先人の方々多数いらっしゃいますが、ブラウザ上で実現しているものはパッと見つからなかったのでブラウザで動くというものを作ってみたかった次第です。


# 構成図
AWSインフラ構築が本業なのでサーバリソースはAWSのEC2を採用し、
EC2にAPIサーバとVOICEVOXサーバを作成しました。
![kouseizu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3339313/08c593e7-2b60-1cfd-c960-a20c1ce9b018.png)

# 処理概要
処理の流れは以下の通りです。
1. 音声認識はWeb Speech API Speech Recognitionを利用
2. 認識した文字列とエンドユーザを識別する文字列をAJAXでFlaskのAPIサーバにPOSTする
3. Flaskは受け取った文字列をOpenAIのAPI(ChatGPT)に送信する
4. FlaskはChatGPTからの返答文字列を受け、VOICEVOXへPOSTする
5. VOICEVOXが音声データをバイナリの文字列で返却されるのでFlaskで受け取る
6. Flaskはバイナリ文字列をbase64エンコードしてユーザに返す
7. ブラウザで音声を再生する

# 環境
・音声認識は対応ブラウザに制限があり、ChromeとFirefoxは動くことは確認できました。
・flaskの環境は以下の通り
```
OS:Amazonlinux2
Python 3.9.10

pip list
Package            Version
------------------ ---------
aiohttp            3.8.4
aiosignal          1.3.1
async-timeout      4.0.2
attrs              23.1.0
blinker            1.6.2
certifi            2022.12.7
charset-normalizer 3.1.0
click              8.1.3
Flask              2.3.2
Flask-Cors         3.0.10
frozenlist         1.3.3
idna               3.4
importlib-metadata 6.6.0
itsdangerous       2.1.2
Jinja2             3.1.2
MarkupSafe         2.1.2
multidict          6.0.4
openai             0.27.6
pip                21.2.4
requests           2.30.0
setuptools         58.1.0
six                1.16.0
tqdm               4.65.0
urllib3            2.0.2
Werkzeug           2.3.3
yarl               1.9.2
zipp               3.15.0
```


# ポイントのコード
※Flaskの基本的な使用方法は省略しています。
※VOICEVOXもWindowsにパッケージからインストールしてコマンドでサーバを起動するだけなので省略しております。
VOICEVOXで参考にさせて頂いた記事
https://qiita.com/yamanohappa/items/b75d069e3cb0708d8709

##### Web Speech API Speech Recognition
参考にさせて頂いた記事
https://qiita.com/hmmrjn/items/4b77a86030ed0071f548
上記記事を引用させていただき、finalTranscriptに音声認識された文字列が格納されます。

```
    SpeechRecognition = webkitSpeechRecognition || SpeechRecognition;
    let recognition = new SpeechRecognition();
  
    recognition.lang = 'ja-JP';
    recognition.interimResults = false;
    recognition.continuous = false;
  
    let finalTranscript = '';
    let audioPlaying = false;
  
    recognition.onresult = (event) => {
      for (let i = event.resultIndex; i < event.results.length; i++) {
        let transcript = event.results[i][0].transcript;
        if (event.results[i].isFinal) {
          finalTranscript += transcript;
        }
      }
```

##### AJAXでFlaskのAPIサーバにPOST
以下関数でFlaskにPOSTと返答を受け音声の再生まで実施しています。
認識された文字列を送るだけなら「userKey: userKey」の部分は不要なのですが
ChatGPTの会話履歴の一時ファイルを作成する都合上ユーザを識別できる情報を合わせて送っています。

```
    function ajaxpostfunction(finalTranscript, userKey) {
        $.ajax({
          type: 'POST',
          url: "FlaskのURL(今回はCloudfrontのURL)",
          data: JSON.stringify({voice: finalTranscript, userKey: userKey}),
          dataType: 'json',
          processData: false,
          contentType: 'application/json',
          timeout: 600000,
          crossDomain: true,
          success: function (data) {
              console.log("success");
              console.log(data);
              if (data.audio_data) {
                const audioData = data.audio_data;
                const audioBlob = base64toBlob(audioData, 'audio/wav');
                const audioElement = new Audio(URL.createObjectURL(audioBlob));
                console.log(data.gptext);
                const receivedMessage = data.gptext;
                addReceivedMessage(receivedMessage);
                playAudio(audioBlob);
              } else {
                console.log("No audio data found");
              }
          },
          error: function (e) {
              console.log("false");
          }
      });
    }
```

##### OpenAIのAPI(ChatGPT)に送信
OpenAIのAPIKeyはOSの環境変数として設定しておく必要があります。
openaiのライブラリをインポートしておき、openai.ChatCompletion.createでデータを送ります。
ChatGPTは過去のやり取りを一緒に送ることで会話を継続できるので、
一時ファイル/tmp/以下に保存し、ChatGPTに送るときに読み込んでいます。

ChatGPTのセッティングや会話過去履歴の送信方法を参考にさせて頂きました。
https://qiita.com/sakasegawa/items/db2cff79bd14faf2c8e0


```
import openai
openai.api_key = os.environ["OPENAI_API_KEY"]

    past_messages = []
    history_file = "/tmp/" + userkey + "_user_history.txt"
    is_file = os.path.isfile(history_file)
    if is_file:
        with open(history_file, 'r') as f:
            s = f.readlines()
            past_messages = json.loads('\n'.join(s))

    else:
        system = {"role": "system", "content": system_settings}
        past_messages.append(system)

    new_message = {"role": "user", "content": inputvoice}
    past_messages.append(new_message)

    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=past_messages
    )
```

##### VOICEVOXへPOST
ChatGPTのレスポンス文字列をVOICEVOXへ送信する前にURLエンコードする必要がありました。
```
URLEncodedTXT = urllib.parse.quote(GPT_response)
```
次に、いきなり音声ファイルを作成することができないようで、先にクエリ作成(/audio_query)というのを行う為以下のようにPOSTします。
speaker=3はノーマルのずんだもんの音声です。
```
    query_url = "http://サーバのローカルIP:50021/audio_query?text=" + URLEncodedTXT + "&speaker=3"
    query_headers = {
           'accept': 'application/json'
            }

    query_response = requests.post(query_url, headers=query_headers)
```
クエリ作成のレスポンスを受け取り音声合成(/synthesis)へPOSTします。
```
    query_content = query_response.content
    data_dict = json.loads(query_content)
    url = "http://サーバのローカルIP:50021/synthesis?speaker=3&enable_interrogative_upspeak=true"

    headers = {
            'content-type': 'application/json',
            'accept': 'audio/wav'
            }
    response = requests.post(url, headers=headers, json=data_dict)
```

##### Flaskからバイナリ文字列をbase64エンコードしてユーザのブラウザに返す
base64_audioにbase64エンコードされた文字列が入ります。
```
        base64_audio = base64.b64encode(response.content).decode('utf-8')
        return {'audio_data': base64_audio, 'gptext': GPT_response}
```

##### ユーザのブラウザで音声を再生するJavascript

以下関数内で
```
function ajaxpostfunction(finalTranscript, userKey) {}
```
successの箇所でplayAudio関数に音声データを渡して自動再生させています。
```
          success: function (data) {
              console.log("success");
              console.log(data);
              if (data.audio_data) {
                const audioData = data.audio_data;
                const audioBlob = base64toBlob(audioData, 'audio/wav');
                const audioElement = new Audio(URL.createObjectURL(audioBlob));
                console.log(data.gptext);
                const receivedMessage = data.gptext;
                addReceivedMessage(receivedMessage);
                playAudio(audioBlob);
              } else {
                console.log("No audio data found");
              }
          },
          error: function (e) {
              console.log("false");
          }
```
base64エンコードされていた音声データをBlob形式？というのにしているみたいです。
Blobが何なのかわ詳しく理解しておらず・・・(ChatGPTに聞いてたら出てきたコードです)
```
    function base64toBlob(base64, mimeType) {
      const bin = atob(base64.replace(/^.*,/, ''));
      const buffer = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) {
        buffer[i] = bin.charCodeAt(i);
      }
      return new Blob([buffer.buffer], {type: mimeType});
    }
```
音声再生関数
```
    function playAudio(audioBlob) {
      const audioElement = new Audio(URL.createObjectURL(audioBlob));
      audioElement.play();
      audioPlaying = true;
      audioElement.addEventListener('ended', () => {
        audioPlaying = false;
        setTimeout(() => {
          recognition.start();
        }, 2000); // 2秒間待機してから音声認識を再開する
      });
      finalTranscript = '';
    }
```


# フロント側の全コード
HTMLとJavascriptで作成しております。
以下内容をhtmlファイルとしてS3へ保存し、Cloudfront＋S3の静的ウェブサイトホストとして公開しました。
Chat-GPTに質問して作成した形なので、かなりつぎはぎです。
``` 
<!DOCTYPE html>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
<html>
<head>
  <title>Chat UI</title>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: Arial, sans-serif;
      background-image: url('face_icon.png');
      background-color: rgba(0, 0, 0, 0.5);
      background-blend-mode: multiply;
    }

    .chat-wrapper {
      display: flex;
      align-items: flex-start;
    }

    .chat-container {
      width: 450px;
      margin: 10px auto;
      border: 1px solid #ccc;
      padding: 10px;
      background-color: #f5f5f5;
    }

    .message {
      margin-bottom: 10px;
      padding: 5px;
      border-radius: 5px;
      background-color: #fff;
    }

    .message.sent {
      text-align: right;
      background-color: #dcf8c6;
    }

    .message.received {
      text-align: left;
      background-color: #fff;
    }


    .voice-input {
      margin-top: 10px;
      text-align: center;
    }

    .voice-input button {
      margin: 5px;
    }

    .input-container {
      margin-top: 10px;
      text-align: center;
    }

    .text-input {
      margin-top: 10px;
      text-align: center;
    }

    .text-input button {
      margin: 5px;
    }
  </style>
</head>
<body>

  <div class="chat-wrapper">
    <div class="chat-container">
      <div class="message sent">Hello!</div>
      <div class="message received">Hi there!</div>
      <!-- Add more messages here -->
    </div>
  </div>
  <div class="input-container">
    <p style="color: white;">「VOICEVOX:ずんだもん」<br></p>
    <p style="color: white;">userkey：<br>
      <input type="text" name="flask-user-key" id="flask-user-key">
    </p>
  </div>
  <div class="text-input">
    <input type="text" id="message-input" placeholder="Type your message">
    <button id="send-btn">Send</button>
  </div>
  <div class="voice-input">
    <button id="start-btn">Start Voice Input</button>
    <button id="stop-btn">Stop Voice Input</button>
  </div>

  <script>
    document.addEventListener('DOMContentLoaded', function() {
      const sendBtn = document.querySelector('#send-btn');
      const messageInput = document.querySelector('#message-input');
      const chatContainer = document.querySelector('.chat-container');

      sendBtn.addEventListener('click', function() {
        const messageText = messageInput.value;
        if (messageText.trim() !== '') {
          const messageDiv = document.createElement('div');
          messageDiv.classList.add('message', 'sent');
          messageDiv.textContent = messageText;
          chatContainer.appendChild(messageDiv);
          messageInput.value = '';

          const userKeyInput = document.querySelector('#flask-user-key');
          const userKey = userKeyInput.value;
          ajaxpostfunction(messageText, userKey)
        }
      });
    });
  </script>

  <script>
    const startBtn = document.querySelector('#start-btn');
    const stopBtn = document.querySelector('#stop-btn');
    const userKeyInput = document.querySelector('#flask-user-key');
  
    SpeechRecognition = webkitSpeechRecognition || SpeechRecognition;
    let recognition = new SpeechRecognition();
  
    recognition.lang = 'ja-JP';
    recognition.interimResults = false;
    recognition.continuous = false;
  
    let finalTranscript = '';
    let audioPlaying = false;
  
    recognition.onresult = (event) => {
      for (let i = event.resultIndex; i < event.results.length; i++) {
        let transcript = event.results[i][0].transcript;
        if (event.results[i].isFinal) {
          finalTranscript += transcript;
        }
      }
      addSentMessage(finalTranscript);// 自分の発言のチャット履歴追記

      const userKey = userKeyInput.value;

      ajaxpostfunction(finalTranscript, userKey);
  
    }

// ページのチャット部分に自分の発言を追加する
    function addSentMessage(message) {
        const chatContainer = document.querySelector('.chat-container');
        const messageDiv = document.createElement('div');
        messageDiv.classList.add('message', 'sent');
        messageDiv.textContent = message;
        chatContainer.appendChild(messageDiv);
    }

// ページのチャット部分に応答があった文字列を追加する
    function addReceivedMessage(message) {
        const chatContainer = document.querySelector('.chat-container');
        const messageDiv = document.createElement('div');
        messageDiv.classList.add('message', 'received');
        messageDiv.textContent = message;
        chatContainer.appendChild(messageDiv);
    }

    function ajaxpostfunction(finalTranscript, userKey) {
        $.ajax({
          type: 'POST',
          url: "FlaskのURL(今回はCloudfrontのURL)",
          data: JSON.stringify({voice: finalTranscript, userKey: userKey}),
          dataType: 'json',
          processData: false,
          contentType: 'application/json',
          cache: false,
          timeout: 600000,
          crossDomain: true,
          success: function (data) {
              console.log("success");
              console.log(data);
              if (data.audio_data) {
                const audioData = data.audio_data;
                const audioBlob = base64toBlob(audioData, 'audio/wav');
                const audioElement = new Audio(URL.createObjectURL(audioBlob));
                console.log(data.gptext);
                const receivedMessage = data.gptext;
                addReceivedMessage(receivedMessage);
                playAudio(audioBlob);
              } else {
                console.log("No audio data found");
              }
          },
          error: function (e) {
              console.log("false");
          }
      });
    }
  
    startBtn.onclick = () => {
      recognition.start();
    }
    stopBtn.onclick = () => {
      recognition.stop();
    }
  
    // 音声の再生をする。認識を一時停止して再生が終わったら自動で再開する。
    function playAudio(audioBlob) {
      const audioElement = new Audio(URL.createObjectURL(audioBlob));
      audioElement.play();
      audioPlaying = true;
      audioElement.addEventListener('ended', () => {
        audioPlaying = false;
        setTimeout(() => {
          recognition.start();
        }, 2000); // 2秒間待機してから音声認識を再開する
      });
      finalTranscript = '';
    }
  
    function base64toBlob(base64, mimeType) {
      const bin = atob(base64.replace(/^.*,/, ''));
      const buffer = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) {
        buffer[i] = bin.charCodeAt(i);
      }
      return new Blob([buffer.buffer], {type: mimeType});
    }
  </script>
</body>
</html>
```

# Flaskの全コード
適当な名前で以下ファイルを保存しコマンド「python ファイル名」で実行させます。
実行前にopenaiのAPIKeyを取得し環境変数として設定する必要があります。

環境変数の設定方法
```
export OPENAI_API_KEY="取得したAPIKey"
```

Flaskコード
```
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
from flask import Flask, request, send_file
from flask_cors import cross_origin
from flask_cors import CORS
from flask import make_response
from datetime import datetime

import os
import openai
import json
import requests
import io
import base64

import urllib.parse


openai.api_key = os.environ["OPENAI_API_KEY"]



app = Flask(__name__)

cors = CORS(app)


@app.route('/', methods=['POST'])
def main():

    data = request.json
    userkey = data.get('userKey')
    print(userkey)

  
    # userkey.txtはflask実行ファイルを同じディレクトリに保存。
    # 内容はただユーザ名を列挙しているだけ。user01　user02・・・と1行1ユーザを記載している。
    file_path = 'userkey.txt'
    target_string = userkey

    if check_string_match(file_path, target_string):
        print("userkey match")

    else:
        print("userkey un match")
        return {'error_response': 'userkey un match'}

    inputvoice = data.get('voice')
    print(inputvoice)

############# AI Settings
    system_settings = """ずんだもんという少女を相手にした対話のシミュレーションを行います。
    ずんだもんは言葉の最後に「なのだ」とつけ、
    感謝を伝える場合は「ありがとうなのだ」というふうに発言します。

    すんだもんの一人称は「ぼく」です。
    返答文字数は多くならないよう、簡潔に答えてください。

    上記例を参考に、性格や口調、言葉の作り方を模倣し、回答を構築してください。
    ではシミュレーションを開始します。"""

############# request to chatgpt
    past_messages = []
    history_file = "/tmp/" + userkey + "_user_history.txt"
    is_file = os.path.isfile(history_file)
    if is_file:
        with open(history_file, 'r') as f:
            s = f.readlines()
            past_messages = json.loads('\n'.join(s))

    else:
        system = {"role": "system", "content": system_settings}
        past_messages.append(system)

    new_message = {"role": "user", "content": inputvoice}
    past_messages.append(new_message)

    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=past_messages
    )
    GPT_response = response.choices[0]["message"]["content"].strip()
    print("GPT response is")
    print(GPT_response)
    GPT_response_dict = {"role": "assistant", "content": GPT_response}
    past_messages.append(GPT_response_dict)

    filewritestring = json.dumps(past_messages, ensure_ascii=False)
    with open(history_file, 'w+') as f:
        f.write(filewritestring)



############# request to chatgpt end
############# Voicevox query request
    URLEncodedTXT = urllib.parse.quote(GPT_response)

    #VOICEVOX Query
    query_url = "http://"VOICEVOXのローカルIP":50021/audio_query?text=" + URLEncodedTXT + "&speaker=3"
    query_headers = {
           'accept': 'application/json'
            }

    query_response = requests.post(query_url, headers=query_headers)

    print("query_response is")
    print(query_response)

    query_content = query_response.content

    print(query_content)
############# Voicevox query request end
############# Voicevox main request
    data_dict = json.loads(query_content)
    url = "http://"VOICEVOXのローカルIP":50021/synthesis?speaker=3&enable_interrogative_upspeak=true"

    headers = {
            'content-type': 'application/json',
            'accept': 'audio/wav'
            }
    response = requests.post(url, headers=headers, json=data_dict)

    print(response)
    if response.ok:
        generated_audio_file = "/tmp/" + userkey + "_generated_audio.wav"
        with open(generated_audio_file, 'wb') as f:
            f.write(response.content)
        base64_audio = base64.b64encode(response.content).decode('utf-8')

        return {'audio_data': base64_audio, 'gptext': GPT_response}

    else:
        print(f'Error: {response.status_code} {response.reason}')
############# Voicevox main request end

# userkeyの有り無しを判別する箇所
def check_string_match(file_path, target_string):
    with open(file_path, 'r') as file:
        for line in file:
            line = line.strip()  # 行末の改行文字を削除
            if line == target_string:
                return True
    return False


if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True, port=80)


```