VOICEVOXをTTSサーバーとしてGoogle Cloud Runにデプロイする

この記事は[TSG Advent Calendar 2021](https://adventar.org/calendars/6510)の20日目の記事です。(28時間の遅刻！)

# VOICEVOXについて
https://github.com/VOICEVOX/voicevox

無料で使える中品質なテキスト読み上げソフトウェアです。GUI(voicevox)、TTSエンジン(voicevox_engine)、その内部のディープラーニングモデルのラッパ(voicevox_core)に分かれており、学習済みモデルが組み込まれたvoicevox_coreバイナリ以外はOSSとして公開されています。そのバイナリについても、利用規約の範囲内で商用・非商用問わず利用可能です。

ディープラーニングモデルではありますがCPU onlyでも十分高速に音声を生成可能であり、この記事ではこのTTSエンジンをGoogle Cloud Runに載せ、テキストをクエリすると音声を返してくれるTTSサーバーとして稼働させてみます。

# Google Cloud Run
https://cloud.google.com/run

Docker imageを投げるだけでスケーリング可能なアプリケーションをデプロイできます。また、アクセスがない時間帯は稼働ノードを0にすることもできるため、利用者が少なく時間帯が偏っているなら課金をかなり安く抑えることができます。（もちろんコールドスタートには時間がかかるため、多少の緩和策はありますが初めの応答がかなり遅くなってしまいます。ここら辺は課金とのトレードオフなので仕方ないですね）

# 本編
私のPCはストレージが厳しくdocker buildが満足に使えないので、この記事ではリポジトリのクローンからデプロイまでGoogle Cloud Platform上のサービスで完結させます。作業の流れは以下のようになります。

1. Compute Engine上でdocker imageを作成
2. Artifact Registryにimageをpush
3. Cloud Runにデプロイ
4. APIを叩く

## 前準備
クレカの登録など、Google Cloud Platformが使えるようになるまでのもろもろの手続きは省略します。

まずは新しいプロジェクトを作成します。この記事では"voicevox-gcr"という名前にします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/ec848e28-e4e8-2283-7ab2-45abcc1cf6c3.png)

そしてCompute Engine, Artifact Registry, Cloud Runを使えるようにします。これは単純にボタンを押して有効化するだけです。

## Compute Engine上でdocker imageを作成
適当なVMインスタンスを建てます。docker imageを作成するのでストレージは多めに確保しておきます。今回はDebian GNU/Linux 10 (buster)の30GBにしました。

ここでのちのArtifact Registryへのアクセスのために、アクセス スコープを「すべての Cloud API に完全アクセス権を許可」に変更します。あまり推奨されないことだと思いますが、必要十分なアクセス権がよく分からなかったのでこうしてしまいます（どうせこのVMはすぐ消すし）。Artifact Registryのドキュメントには書き込み権限についてしか記述されていませんが、どうもこれだけでは足りないようです。

作成したら、そのインスタンスのサービスアカウント`************-compute@developer.gserviceaccount.com`を控えておきます。(Artifact Registryの書き込み権限を後で付与します)

そしてhttps://matsuand.github.io/docs.docker.jp.onthefly/engine/install/debian/ に従ってdockerを入れます。GitHubリポジトリのクローンのためにgitも入れておきます。

```
$ sudo apt update
$ sudo apt install git ca-certificates curl gnupg lsb-release
$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
$ sudo apt update
$ sudo apt install docker-ce docker-ce-cli containerd.io
```

これでdockerが使えるようになりました。

```
$ docker --version
Docker version 20.10.12, build e91ed57
```

ここで、`sudo usermod -a -G docker ${USER}`を実行してからマシンを再起動し、sudoなしでdockerを叩けるようにしておきます。（これをやらないとimageのpush時に何故か失敗します）

次にvoicevox engineをクローンしてきます。このリポジトリにはDockerfileが付属しており、これをビルドするだけでTTSサーバーを作ることができます。Dockerfileにheredocが使われているのでBuildKit環境でビルドします。（参考：https://kakakakakku.hatenablog.com/entry/2021/08/10/085625）

```
$ git clone -b 0.9.3 https://github.com/VOICEVOX/voicevox_engine.git
$ cd voicevox_engine
$ docker buildx build -t vvengine:0.9.3 --target runtime-env .
```

しばし待つとvoicevox engineのdocker imageが完成します。

```
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
vvengine     0.9.3     be303840fa5b   7 minutes ago   2.33GB
```

## Artifact Registryにimageをpush
https://cloud.google.com/artifact-registry/docs/docker/pushing-and-pulling?hl=ja

コンソールのArtifact Registryに行き、新しくリポジトリを作成します。ここで作成したリポジトリの名前とリージョンを控えておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/fbd3fe7b-81ac-3951-b79e-52e4f7c0bec9.png)

そして作成したリポジトリを選択し、「情報パネルを表示」を押して「プリンシパルを追加」を押して、先ほど控えたサービスアカウントにArtifact Registry書き込み権限を付与します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/4428226a-5502-594c-d18a-87a0291081a6.png)


VMインスタンスに戻り、docker imageをこのリポジトリにpushします。

```
$ gcloud auth configure-docker us-central1-docker.pkg.dev
$ docker tag vvengine:0.9.3 us-central1-docker.pkg.dev/voicevox-gcr/voicevox/vvengine:0.9.3
$ docker push us-central1-docker.pkg.dev/voicevox-gcr/voicevox/vvengine:0.9.3
```

これでVMインスタンスでの作業は以上です。もうシャットダウン・削除して構いません。

## Cloud Runにデプロイ
コンソールのCloud Runに行き、先ほどpushしたイメージをデプロイします。

サービスの作成からコンテナイメージを選択し、自動スケーリングやCPUコア、メモリサイズなどのパラメータをよしなに設定します。
また、voicevox engineのデフォルトポートは50021なので、「コンテナポート」を50021に設定する必要があります。

### 注意点
* 認証を「未認証の呼び出しを許可」とした場合、URLを知っている者はだれでもAPIを叩くことができてしまいます。URLを秘密にすれば恐らく大丈夫ですが、本当に大丈夫かどうかはちょっと保証できません。
* voicevox_engineのデフォルト起動ではblockingなTTSエンジンが一つ起動するようになっており、前のクエリの処理中は他のクエリの処理を始めることができません。「コンテナあたりの最大リクエスト数」を2以上にする場合はこの問題を考慮する必要があります。
    * non-blockingなTTSエンジンを起動し活用するには、デプロイ時のコンテナ引数に`--enable_cancellable_synthesis`をセットし、音声合成時は`POST /cancellable_synthesis`を利用するようにします。
    * 同時起動するエンジンの個数は`--init_processes`で指定できます。

## APIを叩く
API documentは `/docs`から見ることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/222311/7e8644d8-8591-fa1a-197d-fa93a5000fe7.png)

TTSを行うためには基本的に2往復する必要があり、まず`POST /audio_query`によって音素情報を計算した後、それを`POST /synthesis` (non-blockingエンジンを起動している場合は `POST /cancellable_synthesis`)に送ることで音声を`audio/wav`として受け取ることができます。これは音素情報を手元で変更してより自由度の高い音声合成を行えるようにするための処置ですが、面倒な場合はvoicevox_engineのrun.pyを変更し、以下のようにテキストから一発で音声に変換するようなAPIを実装する必要があります。

```py
from pydantic import BaseModel
class TTSRequest(BaseModel):
    text: str
    speaker: int
    speed: float = 1.0

...

    @app.post(
        "/tts",
        response_class=FileResponse,
        responses={
            200: {
                "content": {
                    "audio/wav": {"schema": {"type": "string", "format": "binary"}}
                },
            }
        },
        tags=["音声合成"],
        summary="音声合成する",
    )
    def tts(body: TTSRequest):
        text = body.text
        speaker = body.speaker
        accent_phrases = create_accent_phrases(text, speaker_id=speaker)

        query = AudioQuery(
            accent_phrases=accent_phrases,
            speedScale=body.speed,
            pitchScale=0,
            intonationScale=1,
            volumeScale=1.2,
            prePhonemeLength=0.15,
            postPhonemeLength=0.1,
            outputSamplingRate=default_sampling_rate,
            outputStereo=False,
            kana=create_kana(accent_phrases),
        )
        
        wave = engine.synthesis(query=query, speaker_id=speaker)
        with NamedTemporaryFile(delete=False) as f:
            soundfile.write(
                file=f, data=wave, samplerate=query.outputSamplingRate, format="WAV"
            )

        return FileResponse(f.name, media_type="audio/wav")
```

これを用いれば、文章`text`と話者ID`speaker`を指定してPOSTすることで音声を合成することができます。
（non-blocking版は別途作る必要があります)

たとえばNode.js/Typescriptを使っているなら、次のようにクライアントからAPIを叩くことができます。

```ts
import axios, {AxiosError} from 'axios';

// 0.9.3時点では話者は4人で、tsumugiとritsuは感情モデルが無い
const voiceMapping: { [name: string]: { [emo: string]: number } } = {
	metan: {
		normal: 0,
		happiness: 2,
		anger: 4,
		sadness: 6,
	},
	zundamon: {
		normal: 1,
		happiness: 3,
		anger: 5,
		sadness: 7,
	},
	tsumugi: {
		normal: 8,
		happiness: 8,
		anger: 8,
		sadness: 8,
	},
	ritsu: {
		normal: 9,
		happiness: 9,
		anger: 9,
		sadness: 9,
	}
};

const speech = (text: string, voiceType: string, {speed, emotion}) => {
	const postData = {
		text,
		speaker: voiceMapping[voiceType][emotion],
		speed,
	};
	return new Promise((resolve, reject) => {
        // 前述の通りデプロイしたURLは秘匿
		axios.post<Buffer>(process.env.VOICEVOX_API_URL, postData, {
			headers: {
				'content-type': 'application/json',
			},
			responseType: 'arraybuffer',
		}).then((response) => {
			resolve({data: response.data});
		}).catch((reason: AxiosError) => {
			logger.error(`The VoiceVox API server has returned an error: ${reason.response?.data?.toString()}`);
			reject(reason);
		});
	});
};
```

# 余談：コールドスタートについて
ノードの最小稼働数を0にすると、アクセスが無いと自動でコンテナを落としてくれます。しかしその場合新しくアクセスが来た時にコンテナの起動時間が余分にかかってしまいます（コールドスタート）。[この記事](https://cloud.google.com/blog/ja/products/gcp/3-ways-optimize-cloud-run-response-times)によればコンテナイメージのサイズはコールドスタートの時間に影響されないらしいので、Dockerfileをこねくり回すというのはあまり役に立ちません。代わりに、例えば近い将来TTSリクエストが飛ぶであろうという予測ができれば（Discord botならユーザーの入室時など）、実際のTTSリクエストが飛ぶ前に何かのAPIを叩いておくことでこのラグを緩和することができます。

# まとめ
VOICEVOXというTTSエンジンをGoogle Cloud Runに載せ、TTSサーバーとする方法を調べました。この方法では細かな権限設定が存在せず、知らない人にアクセスさせないためにはデプロイURLを秘匿する必要があります。
そしてデプロイしたTTSサーバーのAPIの叩き方を軽くまとめました。例えばTTSをするdiscord botを作る場合などにここで示したspeech関数が役立ちます。
また、GCPの課金については、docker buildに使ったVMインスタンスとArtifact RegistryのストレージとCloud Runで稼働しているノード時間に対し課金されるため、あまりにアクセスが多い場合はこのCloud Runのノード時間に気を付ける必要があります。一方そこまでアクセスが無い場合は、自動スケーリングのおかげで課金時間を節約することができます。