AIと音声会話する Python × Whisper API × ChatGPT API × VOICEVOX 〜バックエンド編〜
はじめに
今回は、AIと音声で会話するアプリのバックエンド側の実装を行なったので、その時に学んだことを記事にして共有してます。構成フローとしては、マイクからの音声入力を、Whisper APIを使用して音声からテキストに変換、chatGTPから得られた返答をVOICEVOXを使用して、音声に変換してます。
アプリケーション概要
今回実装したソースコードは以下のリポジトリに格納してます。
前回の記事ではアプリの環境構築についてまとめています。frontend/ディレクトリでeslint、prettier、stylelintrcの設定を行い、backend/ディレクトリでPython用のlintツール(flake8 や black)の設定をしてます。huskyをルートディレクトリに配置して、フロントエンドとバックエンドの両方のlintが実行されるように設定します。GitHub Actionsとの連携も盛り込みました。
技術概要
今回使用した技術の概要です。
Docker
Next.jsとPythonといった異なる環境下での開発の為、Dockerを導入して、開発環境を統一します。
Flask
PythonのフレームワークはFlaskを使用しました。今回のアプリ要件ではそれほど多くの機能を必要しないため、Djangoは選択肢に含めず、軽量で学習曲線が低いFlaskを採用しました。
Whisper API
OpenAIが提供しているサービスで、音声データから文字起こしができるAPIです。
VOICEVOX
VOICEVOXは無料で様々なキャラクターの音声でテキスト読み上げできるソフトウェアです。
「VOICEVOX:四国めたん」のようにクレジットを記載すれば、商用・非商用で利用可能です。
ChatGPT API
ChatGPT APIはOpenAIによる自然言語処理のための「ChatGPT」を使用して、開発者が自然言語処理を行う際に使用されるAPIです。
Dockerの環境構築
Dockerに触れるのは初めてだったので、以下文献をざっくり読む様にしました。
Dockerが構築される流れがある程度、理解することができ、エラーが発生してもなんとなく場所を特定できる様になりました。
Dockerと仮想化技術の違い
Dockerと仮想化技術はよく比較されることが多いですが、VirtualBoxやVMwareといった仮想化技術は物理マシン(マザーボード、CPU、メモリなど)をソフトウェアに置き換える技術のことを指します。物理マシンのデジタルコピーであり、OSは何を入れても問題なく、実行されるアプリケーション、サービスのすべてを含む完全なシステムとして実行されます。一方でDockerはソフトウェアとその依存関係を一つのコンテナにまとめて、そのコンテナはOS(Linuxカーネル)上で動作します。OSの特定の機能を使用(つまり、コンテナは一部のOS機能に依存)してプロセスとリソースの隔離しており、コンテナはその中で実行されるプロセスが他のプロセスと独立して(そのように見せかけて)動作しています。その為、物理マシンはLinuxの機能が必要であり、コンテナの中もLinuxでなければなりません。ちなみにAWSのEC2も仮想化環境であり、完全に独立したマシンとして動作します。DockerとEC2の違いは、VirtualBoxやVMwareのそれと同じです。
Dockerfile ベストプラクティスについて
Dockerの学習過程で、Dockerfile作成の際のベストプラクティスについてもいろいろ調べてみたので共有します。
1. アプリケーションの切り離し
各コンテナは一つの用途だけのために使用します(ウェブ管理、データベース、メールサーバーなど)。アプリケーションを複数のコンテナに切り離すことで、水平スケールやコンテナの再利用がより簡単になります。
2. マルチステージビルドを活用する
マルチステージビルドを使用して、複数のビルドステージを1つのDockerfileに記述します。それぞれのステージ(build、staging、producttion...etc)はbaseを基盤にして新しいステージを作成することができ、開発用のイメージが必要なツールをすべて含む一方で、本番用のイメージは最小限のファイルのみを含むように設定できるので、最終的なイメージを小さくすることが可能です。
3. 複数行にわたる引数は並びを適切にする
複数行にわたる引数はパッケージの重複指定を防ぐ為にアルファベット順に記載します。
RUN apt-get update && apt-get install -y \
bzr \
cvs \
git \
mercurial \
subversion \
&& rm -rf /var/lib/apt/lists/*
4. .dockerignoreの活用
イメージ構築とは無関係なファイルやディレクトリ内にビルドコンテキストとして含めたくないファイルが存在する場合は .dockerignore
を活用します。
Next.jsイメージの構築
まずは、Dockerfileを作成して、Dockerのイメージを構築します。
# syntax=docker/dockerfile:1
# # base image
FROM node:20.9.0-bookworm-slim as base
WORKDIR /app
RUN chown -R node:node /app && chmod -R 770 /app
# development for local
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["npm", "run", "dev"]
# build
FROM base AS builder
COPY . .
USER node
RUN npm install --loglevel warn
RUN npm run build
- syntax=docker/dockerfile:1: このコメント文により最新のDocker構築機能を必ず利用するように設定します。
- WORKDIR /app: コンテナ内に作業ディレクトリ(/app)を作成
- RUN npm install: 必要なパッケージをインストール
- COPY . .: コンテナ内の作業ディレクトリにソースコードをコピー
- CMD命令: コンテナ起動時に実行するコマンドを指定
- RUN chown -R node:node /app && chmod -R 770 /app: chown コマンドでappディレクトリに対して権限の設定(node ユーザーとグループに対しては読み書き実行の権限を与え、他のユーザーには権限を与えないこと)
FROM node:20.9.0-bookworm-slim as baseについて
コンテナのイメージサイズについては、イメージサイズが大きいことにより、イメージのビルド時間・CI時間が長いことや修正後のトライアンドエラーに時間がかかり、開発生産性が低下するなど弊害が多く発生するため、Dockerイメージはできるだけ、サイズが小さくなるものを選択します。
最終的に、以下の記事を参照して、slim系イメージを選択することにしました。
node:alpine イメージの場合
インストールされるパッケージの pinning固定が困難。過去のパッケージがあるタイミングで削除され、次回以降のビルドで同じパッケージのインストールに成功する保証がない可能性がある。
alpineイメージを選択する最大の理由がイメージサイズだが、近年の人気のbaseイメージサイズに比べてそこまでサイズに大きな差がない。
pythonイメージの構築
pythonのDockerイメージを構築します。
# syntax=docker/dockerfile:1
# --- ビルドステージ ---
FROM python:3.10.13-slim AS builder
WORKDIR /app
# ログを出力
ENV PYTHONUNBUFFERED 1
# 依存関係をインストール
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# ソースコードをコピー
COPY . .
# --- 実行ステージ ---
FROM python:3.10.13-slim AS runtime
WORKDIR /app
# ビルドステージからPython依存関係をコピー
COPY /root/.local /root/.local
COPY /app /app
# 環境変数を設定
ENV PATH=/root/.local:$PATH
CMD ["python", "app/main.py"]
- syntax=docker/dockerfile:1: Next.jsの時と同じように、Docker ビルダがどの Dockerfile を使って解釈するかを指定(バージョン1構文の最新リリースを常に使用可能とする)。
- ENV PYTHONUNBUFFERED: ログメッセージやエラーをリアルタイムにコンソール直接出力するように指定します。デフォルトでは、出力は一時的なメモリ領域に保存され、バッファが一杯になった時だけ、出力されるようになっています。
- COPY命令で
requirements.txt
ファイルをDockerイメージの中に格納します。1つめのパラメータにrequirements.txt
を指定して、src
に何のファイル(群)をイメージにコピーするかをDockerに知らせます。同様に2つめのパラメータはdest
にファイル(群)をどこにコピーしたいかを知らせるようにします。
docker-compose.ymlを定義
Next.jsとPythonを起動するため、docker-composeを定義します。
# docker-compose.yml
version: "3.8"
services:
frontend:
build:
context: ./frontend
ports:
- "3000:3000"
volumes:
- .:/frontend/app
- node_modules:/app/node_modules
tty: true
backend:
build:
context: ./backend
ports:
- "5000:5000"
tty: true
volumes:
node_modules:
- build: ビルドはバックエンドファイルとフロントエンドファイルを指定します。
- volumes: ボリュームに
/app
ディレクトリとnode_modules
ディレクトリを記載して、ローカルのファイルと依存関係のファイルをコンテナ内のディレクトリと同期させます。ローカルでコードの変更を行うと、その変更がコンテナ内でも反映されるようになります。
Dockerコンテナをビルドし、その後起動します。
docker-compose up --build
コンテナおよびネットワークを削除する場合は以下のコマンドを実行します。
docker-compose down
.dockerignoreファイルの作成
.dockerignoreファイルを生成し、不要なファイルがproduction環境のイメージにコピーされるのを防ぐようにします。
# packages
**/node_modules/
# git
**/.git
**/.gitignore
# readme
**/README.md
# build
**/dist
**/build
# secrets
**/.env
バックエンドの実装
次にバックエンドの実装を行います。
仮想環境の構築
venvを使用して仮想環境を構築します。仮想環境を構築しなかった場合、パッケージがグローバルインストールされてしまい、自分のPythonプロジェクトの数が増えれば増えるほど、パッケージのコンフリクトが発生してしまう可能性が高くなります。仮想環境を作成してしまえば、他のプロジェクトのパッケージに影響を与えることなく開発を進めることができます。
python3 -m venv venv # 仮想環境を作成
source venv/bin/activate # 仮想環境を有効化
deactivate # 仮想環境を終了
python-detenvで環境変数を設定する
.envファイルの環境変数を読み込む為にpython-dotenv
を使用します。
.envファイルにOpen APIを設定します。
OPENAI_API_KEY = ''
ORIGIN=""
VOICEVOX_PATH=""
config.py
にpython-dotenvでenvファイルの設定を読み込む処理を追加します。
import os
from dotenv import load_dotenv
from os.path import join, dirname
load_dotenv(verbose=True) # ファイルが見つからない場合は警告を出力する
dotenv_path = join(dirname(__file__), ".env") # 絶対パスの取得
load_dotenv(dotenv_path)
openai_api_key = os.environ.get("OPENAI_API_KEY")
local_pass = os.environ.get("ORIGIN")
voicevox_path = os.environ.get("VOICEVOX_PATH")
それぞれのファイルに、configをimportして、リクエスト時にkeyが読み込まれるように処理を追加します。
from config import openai_api_key
CORSの設定
CORS(Cross-Origin Resource Sharing)はWebアプリに対して異なるオリジンで選択されたリソースへのアクセス権を与える仕組みです。セキュリティ上の観点からブラウザはオリジン間のHTTPリクエストを制限しており、同じオリジンに対してのみリソースのリクエストを行うことができます。それ以外のオリジンに対してはCORSヘッダーを含むことでリソースにアクセスすることが可能です。
FlaskでCORSを設定するために、flask_corsをインストールします。
pip install flask flask_cors
pip install types-Flask-Cors
from flask import Flask, request
from flask_cors import CORS
from config import local_pass
app = Flask(__name__)
CORS(app, resources={r"frontend/app/*": {"origins": local_pass}}) # オリジンの設定
logging.getLogger("flask_cors").level = logging.DEBUG # ログの有効化
インストール後にmain.py
にCORSの設定を追加します。デフォルトでは全てのルートとオリジンを許可しているため、許可するオリジンとパスの起点を追記します。万が一期待通りに動作しない場合も踏まえてログを有効化しておきます。
例外処理の実装
APIと連携際の、例外処理クラスを実装しておきます。
from enum import Enum
class HttpCode(Enum):
OK = 200
NO_CONTENT = 204
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
UNPROCESSABLE_CONTENT = 422
INTERNAL_SERVER_ERROR = 500
class ExceptionsError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
super().__init__(self.message)
def __str__(self) -> str:
return "f{self.status_code}: {self.message}"
APIの実装
以下のコードでWhisper API、ChatGTP APIの実装を行い、音声を文字に起こして返答文を生成します。
import openai
from config import openai_api_key
# 音声をWhisper APIで文字に変換する関数
def voice_to_text(audio_file: str) -> str:
try:
openai.api_key = openai_api_key # Api Keyのセット
transcript = openai.Audio.transcribe("whisper-1", audio_file)
return transcript["text"]
except openai.OpenAIError as error:
print(f"Error in the audio: {error}")
return "Error in processing the audio"
voice_to_text
関数でWhisper APIを使用して音声を文字に変換します。
構築手順は以下の記事を参照しています。
# chat-GTPによる返答文の生成
def think_response(text_message: str) -> str:
try:
openai.api_key = openai_api_key # Api Keyのセット
chat_response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": text_message},
],
)
return chat_response["choices"][0]["message"]["content"]
except openai.OpenAIError as error:
print(f"Error in chat-gtp: {error}")
return "Error in processing the chat-gtp"
Whisper APIやchatGTP APIを使用する際は、OpenAI APIのアカウントを作成してAPIキーを発行する必要がありますが、もし以下のエラーが発生した場合、クレジットカード支払いと上限金額の設定をOpen AI PlatFromで行う必要があります。
openai.error.RateLimitError: You exceeded your current quota, please check your plan and billing details.
voicevoxの実装
次に、VOICEVOXを使用してテキストを音声に変換します。
VOICEVOXエンジンdockerで起動するために、yamlファイルに以下の設定を追加します。
voicevox_engine:
image: voicevox/voicevox_engine:nvidia-ubuntu20.04-latest
ports:
- "50021:50021"
tty: true
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
dockerで起動すると以下のエラーが発生しました。
⠿ Container whisper-chatgtp-api-voicevox_engine-1 Created 0.6s
⠿ Container whisper-chatgtp-api-frontend-1 Created 0.0s
⠿ Container whisper-chatgtp-api-backend-1 Created 0.0s
Attaching to whisper-chatgtp-api-backend-1, whisper-chatgtp-api-frontend-1, whisper-chatgtp-api-voicevox_engine-1
Error response from daemon: could not select device driver "nvidia" with capabilities: [[gpu]]
このエラーはDockerがNVIDIAのGPUドライバーをうまく認識していないときに発生する為、VIDIA Docker Toolkitを公式ドキュメントに従ってインストールしておきます。
import json
import requests
from config import voicevox_path
from utils.exceptions import ExceptionsError, HttpCode
speaker = "speaker"
one = 1
# 音声合成用のクエリ作成処理
def post_audio(text: str) -> bytes | None:
try:
param: dict = {"text": text, "speaker": one}
res = requests.post(f"{voicevox_path}/audio_query", params=param)
res.raise_for_status() # HTTPステータスコードのチェック
return res.json()
except requests.exceptions.HTTPError as http_err:
raise ExceptionsError(
status_code=http_err.response.status_code, message=str(http_err)
)
except requests.exceptions.ConnectionError as conn_err:
raise ExceptionsError(
status_code=HttpCode.INTERNAL_SERVER_ERROR, message=str(conn_err)
)
except Exception as e:
print(f"An error occurred: {e}") # その他のエラー
return None
# 音声をwaveに変換
def post_synthesis(audio_query_res: str) -> bytes | None:
try:
params = {speaker: one}
headers = {"content-type": "application/json"}
audio_query_res_json = json.dumps(audio_query_res)
res = requests.post(
f"{voicevox_path}/synthesis",
data=audio_query_res_json,
params=params,
headers=headers,
)
res.raise_for_status() # HTTPステータスコードのチェック
return res.content
except requests.exceptions.HTTPError as http_err:
raise ExceptionsError(
status_code=http_err.response.status_code, message=str(http_err)
)
except requests.exceptions.ConnectionError as conn_err:
raise ExceptionsError(
status_code=HttpCode.INTERNAL_SERVER_ERROR, message=str(conn_err)
)
except Exception as e:
print(f"An error occurred: {e}") # その他のエラー
return None
-
/audio_query
で音声合成用のクエリを作成します。speaker
はとりあえず1としていますが、フロントエンド実装時に変更できるようにします。text
にはchatGTPからの返答文が格納されます。 -
/synthesis
に音声合成のクエリを渡すことで、waveファイルに変換されます。 - 何らかのAPIエラーが発生した場合は、
except
でエラーが出力されます。
次に音声を出力する為に、pyaudio
をインストールしますが、portaudio.h
でエラーが発生しました。
pip install pyaudio
src/pyaudio/device_api.c:9:10: fatal error: 'portaudio.h' file not found
#include "portaudio.h"
^~~~~~~~~~~~~
1 error generated.
error: command '/usr/bin/clang' failed with exit code 1
[end of output]
note: This error originates from a subprocess, and is likely not a problem with pip.
ERROR: Failed building wheel for pyaudio
調べてみたところ、Pythonの特定のバージョン以上の環境下ではpyaudio
をインストールする場合に失敗する様なので、以下のコマンドでインストールを行います。
pip install --global-option='build_ext' --global-option='-I/usr/local/include' --global-option='-L/usr/local/lib' pyaudio
音声出力を行うコードを記載します。
# 音声出力
def play_wav(wav_file: bytes | None) -> None:
FORMAT = pyaudio.paInt16 # 量子ビット
CHANNELS = 1 # ch数
RATE = 23000 # サンプリング周波数
CHUNK = 2**10 # バッファサイズ
if wav_file is not None:
wr: wave.Wave_read = wave.open(io.BytesIO(wav_file))
p = pyaudio.PyAudio()
stream = p.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
output=True,
)
data = wr.readframes(CHUNK)
while data:
stream.write(data)
data = wr.readframes(CHUNK)
sleep(0.5)
stream.close()
p.terminate()
- FORMAT: 量子化ビット数(音の大小をどれくらいの細かさで記録しているかか)を設定しており、大きくするほど解像度が高くなります。
- CHANNELS: 使用するマイクの本数を指します。
- RATE: サンプリング周波数です。
- CHUNK: データを一時的に蓄えておくバッファサイズを設定します。
- pyaudio.PyAudio.open()でオーディオを再生または録音が可能となります。
- Stream.close()でオーディオデータが書き込まれたストリームを終了して、最終的にシステムのリソースを解放します。
最後にmain.py
にAPIを実行する処理を記載します。
import logging
from flask import Flask, request
from flask_cors import CORS
from utils.exceptions import HttpCode
from local_config import local_pass
from api.response import voice_to_text
from api.voicevox import text_to_voice
app = Flask(__name__)
CORS(app, resources={r"frontend/app/*": {"origins": local_pass}}) # オリジンの設定
logging.getLogger("flask_cors").level = logging.DEBUG # ログの有効化
@app.route("/upload-audio", methods=["POST"])
def upload_audio():
# 音声合成処理
audio_file = request.files["audio"]
if audio_file:
chatGtpRes = voice_to_text(audio_file)
text_to_voice(chatGtpRes)
return {"success": "text to voice"}, HttpCode.OK
else:
return {"error": "No audio file provided"}, HttpCode.BAD_REQUEST
if __name__ == "__main__":
app.run(port=5001, debug=True)
さいごに
今回はバックエンド側の処理について記載しました。次回のフロントエンドではマイク音声処理と声を動的に変換する処理を行っていきたいと思います。最後までお読みくださりありがとうございました。
参考文献
Discussion