Azure OpenAI ServiceのGPT-4o Realtime Audioをdocker環境で動かしてみる

こちらの記事は2024/12/09時点の内容となります。

## はじめに
プレビュー段階ではありますが、Azure OpenAI ServiceのGPT-4o Realtime Audioが使用できるようになりました。
GPT-4oモデルの一種であり、<b>音声での対話</b>が低遅延で行えるモデルです。

これまでは、音声入力した内容を文字起こしする → 生成AIに回答させる → 回答を音声化する という手順を踏む必要があったのですが、このタイムラグが軽減されることになるため、音声を使って生成AIとやりとりするプロダクトには欠かせない存在になりそうです。

## やりたいこと
[aoai-realtime-audio-sdk](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/tree/main) のPythonサンプルコードをvenvではなくdocker環境で動かすことが目標です。

[README.md](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/tree/main/python/samples)を見てみると、venvを使った環境構築手順が紹介されています。
私が関わっている案件ではdockerを使った開発がメインであるため、dockerで動かせるように調整してみたいと思います。


## やらないこと
公式の手順で構築したものとの完全な比較は行いません。
最低限、AOAIの `gpt-4o-realtime-preview` モデルとやり取りし、結果が返ってくるところまでを目指します。
また、この記事は <b>2024/12/09時点</b> の内容であり、積極的に更新は行わない方針ですのでご了承ください。

## (前提)docker環境について
今回構築する環境は、python3.11 + FastAPI が動作するdocker環境を想定しています。
具体的な内容は下記を参考にしてください。

⚠️ 抜粋して記載しているため、このまま実行しても動作しませんのでご了承ください。


### ディレクトリ構成
```
.
├── docker_images/
│   └── app/
│       └── Dockerfile
├── src/
│   ├── bff/                        # サーバサイドのルート
│   │   ├── api/
│   │   │   └── api_v1/
│   │   │       ├── endpoints/
│   │   │       │    └── sample.py  # APIの処理を記述
│   │   │       └── api.py          # APIのルーティング処理を記述
│   │   ├── core/
│   │   │    └── config.py          # サーバサイドの基本設定・定数の管理
│   │   ├── public/                 # フロントエンドのビルド結果が格納される
│   │   ├── schemas/                # APIのリクエスト・レスポンスの型定義
│   │   ├── services/               # APIが呼び出すビジネスロジックを記述
│   │   ├── utils/                  # Azureサービスの操作・汎用的な処理など
│   │   ├── main.py                 # サーバサイドで最初に呼び出される
│   │   └── requirements.txt
│   └── frontend/                   # フロントエンドのルート
│                                    (※)フロントエンドのコードは今回の本題から逸れるため割愛
├── .env                            # 環境変数を格納するファイル(gitignoreすること！)
├── compose.yml
└── Makefile
```

### 主要ファイル

``` Dockerfile:docker_images/app/Dockerfile
ARG DISTROLESS_VER

FROM python:3.11-slim AS builder

WORKDIR /app/src

COPY src/bff/ ./

RUN set -x \
 && apt update -y \
 && apt upgrade -y \
 && \
 : "package install" \
 && pip --no-cache-dir install --upgrade pip \
 && mv requirements.txt ../ \
 && cd .. \
 && pip install --no-cache-dir --target py-packages -r requirements.txt \
 && \
 : "権限を変更" \
 && chown -R 1000:1000 /app

FROM gcr.io/distroless/python3:${DISTROLESS_VER}

WORKDIR /app/src
COPY --from=builder /app ../
ENV PYTHONPATH=/app/py-packages

EXPOSE 8000

CMD ["/app/py-packages/bin/uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

```

```src/bff/api/api_v1/api.py
from api.api_v1.endpoints import sample
from fastapi import APIRouter

api_router = APIRouter()
api_router.include_router(sample.router, prefix="", tags=["sample"])

```

```src/bff/core/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    API_V1_STR: str = "/api/v1"

    class Config:
        case_sensitive = True


settings = Settings()

```

```src/bff/main.py
from api.api_v1.api import api_router
from fastapi import FastAPI
from core.config import settings

app = FastAPI()

app.include_router(api_router, prefix=settings.API_V1_STR)
app.mount("/", StaticFiles(directory="public", html=True), name="static")

```


```src/bff/requirements.txt
fastapi==0.115.*
uvicorn==0.32.*
pydantic-settings==2.6.*
```

```compose.yml
services:
  app:
    build:
      context: .
      dockerfile: ./docker_images/app/Dockerfile
      args:
        DISTROLESS_VER: "debug-nonroot"
    container_name: "sample-app"
    volumes:
      - ./src/bff:/app/src:cached
    command: "/app/py-packages/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
    ports:
      - 8000:8000
    tty: true
    env_file:
      - .env
```



## aoai-realtime-audio-sdkから必要なものを移植する
ようやく本題です。
[aoai-realtime-audio-sdk](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/tree/main) のPythonサンプルコードのうち、[/python/samples/client_sample.py](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/blob/main/python/samples/client_sample.py) を動かすことを考えます。

### 1. client_sample.pyを移植
今回のディレクトリ構成の場合、API内で実行するビジネスロジックは `src/bff/services/` へ置くという決まりにしているので、ここに [/python/samples/client_sample.py](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/blob/main/python/samples/client_sample.py) の内容をそのまま移植します。

```
...
├── src/
│   ├── bff/   
│   │   └── services/
│   │       └── client_sample.py   # client_sample.pyをそのまま移植
...
```

### 2. 環境変数を追加
`client_sample.py` の `with_azure_openai` メソッドで、下記の環境変数が呼ばれています。

```
    endpoint = get_env_var("AZURE_OPENAI_ENDPOINT")
    key = get_env_var("AZURE_OPENAI_API_KEY")
    deployment = get_env_var("AZURE_OPENAI_DEPLOYMENT")
```

これらに相当する値を `.env` ファイルに追加しておきます。
⚠️ このファイルはgitignoreするなどして適切に管理するようにしてください。


### 3. rtclientを移植

さて、`client_sample.py` の内容を眺めると、下記のようなimportがあります。


```aoai-realtime-audio-sdk/python/samples/client_sample.py
from rtclient import (
    InputAudioTranscription,
    RTAudioContent,
    RTClient,
    RTFunctionCallItem,
    RTInputAudioItem,
    RTMessageItem,
    RTResponse,
    ServerVAD,
)
```

どうやら [/python/rtclient](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/tree/main/python/rtclient) の内容をimportしているようです。
必要そうなので、このディレクトリも丸ごと移植します。

```
...
├── src/
│   ├── bff/   
│   │   └── rtclient/   # 丸ごと移植
...
```

### 4. requirements.txtを移植

追加でライブラリが必要になりそうな予感がするので [/python/samples/requirements.txt](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/blob/main/python/samples/requirements.txt) を確認してみたところ、下記の4つのライブラリが含まれていました。

```aoai-realtime-audio-sdk/python/samples/requirements.txt
python-dotenv
soundfile
numpy
scipy
```

環境変数の展開は `compose.yml` でまとめて行いたいので、`python-dotenv` は今回追加しないでおきます。

```src/bff/requirements.txt
fastapi==0.115.*
uvicorn==0.32.*
pydantic-settings==2.6.*
soundfile
numpy
scipy
```

### 5. APIの追加

パッと移植できるところは移植してしまったので、とりあえずAPIが実行できるように下記のコードを追加します。


```src/bff/api/api_v1/endpoints/sample.py
from fastapi import (APIRouter, File, Form, UploadFile)
from fastapi.responses import JSONResponse
from services.client_sample import with_azure_openai

router = APIRouter()

@router.post("/process_audio/")
async def process_audio(audio_file: UploadFile = File(...), out_dir: str = Form(...)):
    temp_file_path = f"/tmp/{audio_file.filename}"
    with open(temp_file_path, "wb") as temp_file:
        temp_file.write(await audio_file.read())

    if not os.path.exists(out_dir):
        os.makedirs(out_dir)

    try:
        await with_azure_openai(temp_file_path, out_dir)
    except Exception as e:
        return JSONResponse(status_code=500, content={"message": str(e)})

    return {"message": "Processing completed successfully"}
```

### 6. 環境立ち上げ & エラー解消

`docker compose up --build` コマンドでdocker環境を立ち上げます。
下記のようなエラーが出ました。

```
...
   File "/app/src/services/interview.py", line 10, in <module>
     from azure.core.credentials import AzureKeyCredential
 ModuleNotFoundError: No module named 'azure'
```

調べてみると、`azure-core` というライブラリが必要なようです。
`src/bff/requirements.txt` に追加して再度docker環境を立ち上げると、次のエラーが出ました。

```
...
   File "/app/src/rtclient/low_level_client.py", line 10, in <module>
     from aiohttp import ClientSession, WSMsgType, WSServerHandshakeError
 ModuleNotFoundError: No module named 'aiohttp'
```

`aiohttp` も追加して再度docker環境を立ち上げます。
二度あることは三度ある。またエラーが出ました。

```
...
 RuntimeError: Form data requires "python-multipart" to be installed.
 You can install "python-multipart" with:

 pip install python-multipart
```

ご丁寧に `python-multipart` をインストールせよとのことなので、これも`src/bff/requirements.txt` に追加します。
ここまで対応すると、ようやくdocker環境が正常に起動しました！

最終的に `src/bff/requirements.txt` はこのような状態になりました。
それぞれ最新バージョンの指定を追加してあります。

```src/bff/requirements.txt
fastapi==0.115.*
uvicorn==0.32.*
pydantic-settings==2.6.*
soundfile==0.12.1
numpy==2.1.3
scipy==1.14.1
azure-core==1.32.0
aiohttp==3.11.10
python-multipart==0.0.19
```

本来であれば公式の手順に従って `.whl` ファイルを展開すべきところを、無理やり強行突破したような感じです。
sdkに含まれる他の機能も試す場合は、更に必要なライブラリが出てくるかもしれません。

### 7. 実行

APIの実行はFastAPIの [自動ドキュメント生成](https://fastapi.tiangolo.com/ja/features/#_3) の機能を使って行います。
前段でdocker環境が起動していれば、`http://localhost:8000/docs` にアクセスすることでSwagger UIの画面が表示されます。

記事内のAPIパスと若干異なりますが、下記のようなイメージで無事に実行確認することができました👏

![スクリーンショット 2024-12-10 2.09.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390780/62073859-3743-3f08-a4d6-2281fef1f687.png)
![スクリーンショット 2024-12-10 2.09.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390780/94602b86-38c3-0fc1-55a1-df41dcc650d3.png)

リクエストに使うファイルは、 [aoai-realtime-audio-sdk/python/samples/input](https://github.com/Azure-Samples/aoai-realtime-audio-sdk/tree/main/python/samples/input) 内にあったファイルを拝借しました。
また、リクエストとして設定した `out_dir` 内には 結果ファイル(`.wav` と `.txt` の2種類)が出力されていました。
`.wav` の音声を文字起こしした内容が `.txt` に反映されているようです。

## さいごに
とりあえず動かすところまでは確認できたので、今後はフロントエンドとの繋ぎこみやストリーミングに対応していきたいと思います。
API自体がプレビュー段階なので、今後SDKもより使いやすいものになっていくかな…？と期待しています。

## 参考文献
下記の記事を参考にさせていただきました！ありがとうございます。

https://zenn.dev/headwaters/articles/44d4152b85cac1

https://qiita.com/potofo/items/0ac5fe2def0379f0f62e