見出し画像

VOICEVOXのレンタルGPUサーバーを立てる

こんにちは、ニケです。お久しぶりです。

今回はVOICEVOXの外部サーバー建てについに手を出したので、その結果と方法を共有したいと思います。

レンタルサーバーサービスは Modal を使いたいと思います。


VOICEVOXとは

言わずとしれた音声合成ソフトです。ずんだもんが有名ですね。
様々なキャラから好きな声を選んでテキストから音声ファイルを作成することができます。

利用規約はキャラによって異なるため注意が必要ですが、割と何にでも使えるので動画制作ではお世話になってる方も多いと思います。

ちなみに私のお気に入りは、小夜/SAYO ちゃんです。

通常は各OSにあったVOICEVOXアプリをダウンロードし、起動するだけでAPIを実行できるサーバーが立てられます。すごい簡単。
ただ私は複数の環境を使っていると、インターネットを介して使用する場合もあるので、どこかネット上のサービスを利用して立ち上げておいたら楽だなと思い、今回の検証をするに当たりました。

ちなみにこんなシステムで利用させてもらっています。

Modalとは

クラウドでGPUを使った操作ができるクラウドコンピューティングツールです。

私も最近知りましたが、画像生成のStreamDiffusionやLLMの推論環境を簡単に作ることができるのでかなり注目しています。

セットアップも簡単で、設定はpythonコードをベースにちょっとmodal用の記述をすればいいだけなのでそんなに複雑じゃありません。

使用した分だけ費用が発生する従量課金制なのも嬉しいですね。
そもそもの料金もかなり安いので、個人でバカデカGPUを持っていない方は重宝するかもしれません。

サーバーを立ち上げる

では早速VOICEVOXのGPUサーバーを立ち上げてみましょう。
Modalはサーバーレスな環境を立ち上げられるのが売りですが、今回はサーバーを立てます。

https://modal.com/

1. Modalクライアントをインストール

まずはModalのサイトを開き、サインアップしてください。
githubでログインできるのは嬉しい。

サインアップしたらチュートリアルにしたがって下記のコマンドを実行しましょう。

pip install modal
modal token new

これだけです。

2. ソースコードのデプロイ

下記のコードに適当な名前をつけてmodalコマンドでデプロイしましょう。

# voicevox.py

import modal
import sys
import socket
import subprocess

# Dockerイメージの設定
image = (
    modal.Image.from_registry("voicevox/voicevox_engine:nvidia-ubuntu20.04-latest", add_python="3.11")
    .pip_install("git+https://github.com/modal-labs/asgiproxy.git")
)

# Modalのスタブ設定
stub = modal.Stub(name="voicevox", image=image)

# ホストとポートの設定
HOST = "0.0.0.0"
PORT = "50021"


def spawn_server():
    """サーバーを起動する関数"""
    # コマンドの設定
    command = [
        "gosu", "user", "/opt/python/bin/python3", "/opt/voicevox_engine/run.py",
        "--voicelib_dir", "/opt/voicevox_core/",
        "--runtime_dir", "/opt/onnxruntime/lib",
        "--host", HOST, "--use_gpu", "--cors_policy_mode", "all"
    ]

    # サブプロセスとしてサーバーを起動
    process = subprocess.Popen(command, stdout=sys.stdout, stderr=sys.stderr)

    # サーバーが起動するまで待機
    while True:
        try:
            socket.create_connection((HOST, int(PORT)), timeout=1).close()
            print("Webserver ready!")
            return process
        except (socket.timeout, ConnectionRefusedError):
            retcode = process.poll()
            if retcode is not None:
                raise RuntimeError(f"launcher exited unexpectedly with code {retcode}")


@stub.function(gpu="t4", timeout=10000, container_idle_timeout=1200)
@modal.asgi_app()
def run():
    """ASGIアプリケーションを実行する関数"""
    from asgiproxy.config import BaseURLProxyConfigMixin, ProxyConfig
    from asgiproxy.context import ProxyContext
    from asgiproxy.simple_proxy import make_simple_proxy_app

    spawn_server()

    # プロキシ設定
    config = type(
        "Config",
        (BaseURLProxyConfigMixin, ProxyConfig),
        {"upstream_base_url": f"http://{HOST}:{PORT}", "rewrite_host_header": f"{HOST}:{PORT}"},
    )()
    proxy_context = ProxyContext(config)
    return make_simple_proxy_app(proxy_context)
modal deploy voicevox.py

これだけです。

コード内でやってることは、VOICEVOX公式が配布しているDockerコンテナを起動し、コンテナの規定スクリプトでuvicornサーバーを立ち上げ、それに対してプロキシサーバーを介して接続する、って感じです。

なんだかまどろっこしいことしていますが、VOICEVOXコンテナをそのまま使う方法だとこうやるしかないはず…。
もっと簡単にできる方がいたら教えて下さい。

コードの詳細が気になる方は下記のサンプルコード&解説を読んでもらえると理解しやすいかもしれません。

https://modal.com/docs/examples/serve_streamlit#spawning-the-streamlit-server

3. 実行!

実行後下記のような出力が出るはずです。(初回はもっと多いかも)

> modal deploy voicevox.py
✓ Created objects.
├── 🔨 Created mount /Volumes/T7/WorkSpace/jobs/cotegg/tailorapp-iza/voicevox.py
└── 🔨 Created run => https://tegnike--nikechan-voicevox-run.modal.run
✓ App deployed! 🎉

その後、Modalのサイトに行き、ダッシュボードを確認すると "Deployed Apps" に voicevox というのができてるのがわかると思います。

ちなみにこの voicevox という名称は、下記の部分で設定できるので、適宜調整してください。

stub = modal.Stub(name="voicevox", image=image)

この画像の左下にあるのがサーバーのURLです。
https://tegnike--voicevox-run.modal.run(このURLは停止済みです)

各種VOICEVOX APIの使い方はこちらを参考にしてください。


⚠注意

今回の方法ではサーバーが常時稼働しているので常に課金されます。上記のコードではT4を使用しているので $0.59/h ですが、ずっと稼働させていると馬鹿にできない額になってしまいます。
必要な時だけ起動するなら、下記のコマンドが良いでしょう。

modal serve voicevox.py

結果

VOICEVOXでよく使う、以下のリクエストをPostmanからいろんなVOICEVOXサーバーに送って比較してみます。

/audio_query?text=隣の柿はよく柿食う客だ&speaker=46

惨敗しました。
流石にローカルで動いているやつには勝てなかった。

とはいえこれでも使えなくはない速度なので気になる方はぜひ試してみてください。

⚠ ReactからPOSTリクエストを送ると何故かエラーになることがありました。
'Transfer-Encoding': 'chunked' をheders情報に含めたら直ったので同じ症状が出た方は試してみてください。

INFO:     127.0.0.1:51459 - "OPTIONS /audio_query?speaker=46&text=%20%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86%E3%81%94%E3%81%96%E3%81%84%E3%81%BE%E3%81%99%EF%BC%81 HTTP/1.1" 200 OK
OPTIONS /audio_query -> 200 OK  (duration: 428.4 ms)
WARNING:  Invalid HTTP request received.
Traceback (most recent call last):
  File "/home/user/.local/lib/python3.11/site-packages/uvicorn/protocols/http/h11_impl.py", line 136, in handle_events
    event = self.conn.next_event()
            ^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/.local/lib/python3.11/site-packages/h11/_connection.py", line 487, in next_event
    exc._reraise_as_remote_protocol_error()
  File "/home/user/.local/lib/python3.11/site-packages/h11/_util.py", line 77, in _reraise_as_remote_protocol_error
    raise self
  File "/home/user/.local/lib/python3.11/site-packages/h11/_connection.py", line 469, in next_event
    event = self._extract_next_receive_event()
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/.local/lib/python3.11/site-packages/h11/_connection.py", line 411, in _extract_next_receive_event
    event = self._reader(self._receive_buffer)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/.local/lib/python3.11/site-packages/h11/_readers.py", line 177, in __call__
    matches = validate(
              ^^^^^^^^^
  File "/home/user/.local/lib/python3.11/site-packages/h11/_util.py", line 91, in validate
    raise LocalProtocolError(msg)
h11._util.RemoteProtocolError: illegal chunk header: bytearray(b'POST /audio_query?speaker=46&text=%20%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86%E3%81%94%E3%81%96%E3%81%84%E3%81%BE%E3%81%99%EF%BC%81 HTTP/1.1\r\n')
2024-02-23T12:07:38+0000 Canceling remaining unfinished task: <Task pending name='Task-585' coro=<Queue.get() running at /usr/local/lib/python3.11/asyncio/queues.py:158> wait_for=<Future pending cb=[Task.task_wakeup()]> cb=[set.discard()]>
Task exception was never retrieved
future: <Task finished name='Task-583' coro=<make_simple_proxy_app.<locals>.app() done, defined at /usr/local/lib/python3.11/site-packages/asgiproxy/simple_proxy.py:22> exception=ServerDisconnectedError('Server disconnected')>
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/asgiproxy/simple_proxy.py", line 27, in app
    return await proxy_http_handler(
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/asgiproxy/proxies/http.py", line 92, in proxy_http
    proxy_response = await get_proxy_response(
                     ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/asgiproxy/proxies/http.py", line 59, in get_proxy_response
    return await context.session.request(**kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/aiohttp/client.py", line 560, in _request
    await resp.start(conn)
  File "/usr/local/lib/python3.11/site-packages/aiohttp/client_reqrep.py", line 899, in start
    message, payload = await protocol.read()  # type: ignore[union-attr]
                       ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/aiohttp/streams.py", line 616, in read
    await self._waiter
aiohttp.client_exceptions.ServerDisconnectedError: Server disconnected

おまけ

参考サイト


この記事が気に入ったらサポートをしてみませんか?