🦓

AIと音声会話する Python × Whisper API × ChatGPT API × VOICEVOX 〜バックエンド編〜

2023/12/22に公開

https://qiita.com/advent-calendar/2023/arsaga



はじめに

今回は、AIと音声で会話するアプリのバックエンド側の実装を行なったので、その時に学んだことを記事にして共有してます。構成フローとしては、マイクからの音声入力を、Whisper APIを使用して音声からテキストに変換、chatGTPから得られた返答をVOICEVOXを使用して、音声に変換してます。



アプリケーション概要

今回実装したソースコードは以下のリポジトリに格納してます。
https://github.com/MASAKi-cell/Whisper-chatGTP-Api


前回の記事ではアプリの環境構築についてまとめています。frontend/ディレクトリでeslint、prettier、stylelintrcの設定を行い、backend/ディレクトリでPython用のlintツール(flake8 や black)の設定をしてます。huskyをルートディレクトリに配置して、フロントエンドとバックエンドの両方のlintが実行されるように設定します。GitHub Actionsとの連携も盛り込みました。

https://zenn.dev/arsaga/articles/0fdee431a8374a



技術概要

今回使用した技術の概要です。

Docker

Next.jsとPythonといった異なる環境下での開発の為、Dockerを導入して、開発環境を統一します。

https://docs.docker.jp/



Flask

PythonのフレームワークはFlaskを使用しました。今回のアプリ要件ではそれほど多くの機能を必要しないため、Djangoは選択肢に含めず、軽量で学習曲線が低いFlaskを採用しました。

https://msiz07-flask-docs-ja.readthedocs.io/ja/latest/



Whisper API

OpenAIが提供しているサービスで、音声データから文字起こしができるAPIです。

https://openai.com/blog/introducing-chatgpt-and-whisper-apis



VOICEVOX

VOICEVOXは無料で様々なキャラクターの音声でテキスト読み上げできるソフトウェアです。
「VOICEVOX:四国めたん」のようにクレジットを記載すれば、商用・非商用で利用可能です。

https://voicevox.hiroshiba.jp/



ChatGPT API

ChatGPT APIはOpenAIによる自然言語処理のための「ChatGPT」を使用して、開発者が自然言語処理を行う際に使用されるAPIです。



Dockerの環境構築

Dockerに触れるのは初めてだったので、以下文献をざっくり読む様にしました。

https://www.amazon.co.jp/gp/product/B08T961HKP/ref=ppx_yo_dt_b_d_asin_title_351_o01?ie=UTF8&psc=1

https://zenn.dev/suzuki_hoge/books/2022-03-docker-practice-8ae36c33424b59

https://www.youtube.com/watch?v=8vXoMqWgbQQ

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を基盤にして新しいステージを作成することができ、開発用のイメージが必要なツールをすべて含む一方で、本番用のイメージは最小限のファイルのみを含むように設定できるので、最終的なイメージを小さくすることが可能です。

https://youtu.be/Z0lpNSC1KbM


3. 複数行にわたる引数は並びを適切にする

複数行にわたる引数はパッケージの重複指定を防ぐ為にアルファベット順に記載します。

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion \
  && rm -rf /var/lib/apt/lists/*


4. .dockerignoreの活用

イメージ構築とは無関係なファイルやディレクトリ内にビルドコンテキストとして含めたくないファイルが存在する場合は .dockerignoreを活用します。


https://docs.docker.jp/develop/develop-images/dockerfile_best-practices.html

https://zenn.dev/forcia_tech/articles/20210716_docker_best_practice#信頼できるベースイメージを使用する



Next.jsイメージの構築

まずは、Dockerfileを作成して、Dockerのイメージを構築します。

frontend/Dockerfile
# 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 --chown=node:node . .
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 ユーザーとグループに対しては読み書き実行の権限を与え、他のユーザーには権限を与えないこと)

https://docs.docker.jp/build/guide/intro.html#build-environment-setup


FROM node:20.9.0-bookworm-slim as baseについて

コンテナのイメージサイズについては、イメージサイズが大きいことにより、イメージのビルド時間・CI時間が長いことや修正後のトライアンドエラーに時間がかかり、開発生産性が低下するなど弊害が多く発生するため、Dockerイメージはできるだけ、サイズが小さくなるものを選択します。

最終的に、以下の記事を参照して、slim系イメージを選択することにしました。

node:alpine イメージの場合

インストールされるパッケージの pinning固定が困難。過去のパッケージがあるタイミングで削除され、次回以降のビルドで同じパッケージのインストールに成功する保証がない可能性がある。
alpineイメージを選択する最大の理由がイメージサイズだが、近年の人気のbaseイメージサイズに比べてそこまでサイズに大きな差がない。

https://zenn.dev/jrsyo/articles/e42de409e62f5d



pythonイメージの構築

pythonのDockerイメージを構築します。

backend/Dockerfile
# 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 --from=builder /root/.local /root/.local
COPY --from=builder /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にファイル(群)をどこにコピーしたいかを知らせるようにします。

https://docs.docker.jp/language/python/build-images.html

https://www.lifewithpython.com/2021/05/python-docker-env-vars.html



docker-compose.ymlを定義

Next.jsとPythonを起動するため、docker-composeを定義します。

docker-compose.yml
# 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環境のイメージにコピーされるのを防ぐようにします。

.dockerignore
# 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                   # 仮想環境を終了

https://zenn.dev/os1ma/articles/935f6e653f1052



python-detenvで環境変数を設定する

.envファイルの環境変数を読み込む為にpython-dotenvを使用します。

https://pypi.org/project/python-dotenv/

.envファイルにOpen APIを設定します。

OPENAI_API_KEY = ''
ORIGIN=""
VOICEVOX_PATH=""


config.pyにpython-dotenvでenvファイルの設定を読み込む処理を追加します。

config.py
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

https://flask-cors.readthedocs.io/en/latest/


main.py
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と連携際の、例外処理クラスを実装しておきます。

utils/exceptions.py
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の実装を行い、音声を文字に起こして返答文を生成します。

response.py
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を使用して音声を文字に変換します。

構築手順は以下の記事を参照しています。
https://note.com/npaka/n/nda64330358f8



response.py
# 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.

https://qiita.com/kotattsu3/items/d6533adc785ee8509e2c



voicevoxの実装

次に、VOICEVOXを使用してテキストを音声に変換します。

https://voicevox.github.io/voicevox_engine/api/

https://github.com/VOICEVOX/voicevox_engine#docker-イメージ


VOICEVOXエンジンdockerで起動するために、yamlファイルに以下の設定を追加します。

docker-compose.yml
  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を公式ドキュメントに従ってインストールしておきます。

https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html



voicevox.py
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

https://stackoverflow.com/questions/33513522/when-installing-pyaudio-pip-cannot-find-portaudio-h-in-usr-local-include



音声出力を行うコードを記載します。

voicevox.py
# 音声出力
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()でオーディオデータが書き込まれたストリームを終了して、最終的にシステムのリソースを解放します。

https://moromisenpy.com/pyaudio/



最後にmain.pyにAPIを実行する処理を記載します。

main.py
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)



さいごに

今回はバックエンド側の処理について記載しました。次回のフロントエンドではマイク音声処理と声を動的に変換する処理を行っていきたいと思います。最後までお読みくださりありがとうございました。



参考文献

https://ryurinblog.com/programming/python_next_build1/

https://zenn.dev/umyomyomyon/articles/5f07abe67a289b

https://nikkie-ftnext.hatenablog.com/entry/my-first-shion-whisper-api-file-and-microphone

https://book.st-hakky.com/docs/about-openai/

https://zenn.dev/suzuki_hoge/books/2022-03-docker-practice-8ae36c33424b59

https://zenn.dev/hathle/books/next-voicevox-book

Arsaga Developers Blog

Discussion