お久しぶりです。いにわです。
AIを利用して作成したものを記事に書いていくことにしました。公開ツールなのでよければ使ってみてね。
通話で使えるDiscord読み上げBOTを自作しました。
iniwa/discord_tts_bot
として公開しています。
なんで作ったのか
読み上げBOT自体はDiscord界隈に大量に居るんですが、自分の使い方だと色々と噛み合いませんでした‥‥。
- 多人数に使われている人気BOTは、アクセスが集中する時間帯に明確に動作が不安定になる
- 通話途中で音声が止まる、コマンドが効かない等
- だったら自分で作って自分のサーバーで回せばいい という発想
- どうせ作るなら24時間立ち上がりっぱなしにしておきたい
- 友人にも使って貰う想定
- Windows PCを点けっぱなしは電気代的にも嫌なので、ラズパイで動かす前提で
- ラズパイ前提となると当然軽負荷を意識した作りにする必要がある
‥‥という流れで組み始めました。
車輪の再発明 というよりは、自分の環境(ラズパイ4・常時稼働)に合うものが欲しかった、という感じです。
構成
こんな感じのシンプル構成。外部API(クラウドの読み上げサービス)には一切依存しない、ローカル完結型です。
Raspberry Pi 4 (ARM64)
└─ Docker container
├─ Python 3.11 / discord.py
├─ Open JTalk (音声合成)
├─ MeCab + naist-jdic (形態素解析)
├─ FFmpeg (再生)
└─ /ram_cache (tmpfs RAMディスク)
├─ dic/ : MeCab辞書のコピー
├─ voice.htsvoice : 音声モデルのコピー
└─ output_*.wav : 生成した音声の一時置き場
技術スタックは下記の通りです。
| 用途 | 採用 |
|---|---|
| Bot本体 | Python 3.11 / discord.py |
| 音声合成 | Open JTalk + HTS Voice “Mei” |
| 形態素解析 | MeCab + naist-jdic |
| ローマ字→ひらがな | romkan |
| 音声再生 | FFmpeg |
| 配布 | Docker / GHCR |
クラウドAPIを使えば音声品質はもっと上がるんですが、
- APIキーの管理が必要
- 従量課金になる
- 外部サービスが落ちると道連れ
というのが運用上面倒なので、ローカル完結で割り切り ました。
Open JTalkの「メイ」ちゃんは古典的な合成音声って感じの声ですが、通話の補助として読み上げる用途なら十分聞き取れます。
軽量化の工夫
ラズパイで24時間回す前提なので、SDカードの寿命とCPU負荷を意識した作りにしました。
tmpfs (RAMディスク) で SDカード書き込みを回避
ラズパイのSDカードは書き込み回数に上限があり、頻繁な書き込みで壊れます。
読み上げBOTは「メッセージごとにwavを生成→再生→削除」を繰り返すので、何も考えずに作ると1メッセージごとにSDカードへ書き込みが発生して寿命を確実に縮めます。
そこで、Docker Composeで /ram_cache を tmpfs マウントし、起動時に下記をRAMにコピー&配置します。
- MeCab 辞書(
/var/lib/mecab/dic/open-jtalk/naist-jdic) - HTS Voice 音声モデル(
mei_normal.htsvoice) - 一時生成される wav ファイル
これで読み上げ処理中のディスクI/Oはゼロ。SDカードに触るのは辞書ファイル word_dict.json を更新する時くらいです。
compose 側で必要なのは下記の指定だけで、あとは bot.py 起動時に辞書と音声モデルが /ram_cache にコピーされます。
services:
discord-bot:
tmpfs:
- /ram_cache
副次効果として辞書も音声モデルもRAMから読まれるので、合成自体もちょっと速くなります。
起動時ウォームアップ
Open JTalk と FFmpeg は初回呼び出しが目に見えて遅いです(DLL読み込みやキャッシュ未ロード)。
読み上げBOTで初回が数秒詰まると体感がかなり悪いので、起動時とVC参加時にダミーの「あ」を一度生成・通過 させてキャッシュを温めるようにしました。
# 起動時: Open JTalkを一度叩いてウォームアップ
subprocess.run(
["open_jtalk", "-x", DIC_PATH, "-m", VOICE_PATH, "-ow", _warmup_path],
input="テスト".encode("utf-8"),
stderr=subprocess.DEVNULL,
)
# /join 時: FFmpegもウォームアップ(Discordには流さずプロセスだけ起動)
await loop.run_in_executor(None, lambda: subprocess.run(
["ffmpeg", "-i", warmup_path, "-f", "null", "-"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
))
これで1回目のメッセージから引っ掛かりなく読み上げしてくれるようになる‥‥はずなんですが、あまり上手くいってないように感じています。用途上クリティカルな問題ではないので諦めました。
機能を盛らない
人気BOTと比べると色々と見劣りする部分はあるのですが、軽量であることを重視しているため今後も機能追加の予定はありません。
ラズパイでも快適に動くことを大前提として組んでいます。
できる事
コマンドはシンプルに7つだけ。
| コマンド | 説明 |
|---|---|
/join |
ボイスチャンネルに参加し、読み上げを開始 |
/bye |
ボイスチャンネルから退出 |
/add <word> <reading> |
辞書に単語を登録 |
/remove <word> |
辞書から単語を削除 |
/list |
登録単語の一覧を表示 |
/notify |
VC参加通知の読み上げを ON/OFF |
/help |
コマンド一覧を表示 |
読み上げ仕様でこだわった部分は下記。
/joinを実行したテキストチャンネルのメッセージを読み上げ対象にする- 別のテキストチャンネルで
/joinし直すと対象が切り替わる
- 別のテキストチャンネルで
- URL は「ユーアールエル」に変換(URL本文を全部読み上げると地獄)
- カスタム絵文字は絵文字名のみ読み上げ
- 50文字を超えるメッセージは「以下省略」で打ち切り
- 辞書は文字数の長い単語から優先適用(“discord” を登録しつつ “wd” も登録、みたいな運用で衝突しない)
- BOT のいるVCから全員退出すると自動で切断(消し忘れ防止)
/notifyON で VC入退室を読み上げ
辞書まわりは実用上ちょっとした工夫を入れています。
たとえば、私は「いにわ」という名前なのですが、w を「わら」として辞書登録していると、何も対策しないと iniwa が 「いにわらえー」と読まれてしまう という地味な問題が発生します。
(w が「わら」に置換され、残った a が単独のアルファベットとして「エー」と読まれるため)
そこで w / ww のような 草系単語は前後に英数字がある場合は置換しない ようにしてあります。なお全角wも w として別途登録可能(半角wとの混在対策)。
# 'w'+ にマッチする登録語は単語境界判定を入れる
if re.fullmatch(r'w+', word, re.IGNORECASE):
pattern = r'(?<![a-zA-Z0-9])' + re.escape(word) + r'(?![a-zA-Z0-9])'
text = re.sub(pattern, reading, text)
else:
text = text.replace(word, reading)
地味ですが、これを入れていないと自分の名前が読み上げのたびに「いにわらえー」になってしまうので‥‥。
セットアップ
GHCRにイメージを上げてあるので、ラズパイ側ではdocker-compose一発です。
1. Discord Bot Token を取得
Discord Developer Portal
でアプリケーション作成 → Bot 追加 → Token をコピー。
Privileged Gateway Intents の MESSAGE CONTENT INTENT はONにしておくこと(メッセージを読むのに必要)。
2. 辞書ファイルを用意
ホスト側に word_dict.json と settings.json を作っておきます。空でOK。
echo '{}' > word_dict.json
echo '{}' > settings.json
3. docker-compose.yaml を書いて起動
下記の YAML を docker-compose.yaml として保存します。
services:
discord-bot:
image: ghcr.io/iniwa/discord_tts_bot:latest
container_name: discord_tts_bot
tmpfs:
- /ram_cache
volumes:
- /path/to/word_dict.json:/app/word_dict.json
- /path/to/settings.json:/app/settings.json
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN}
- TZ=Asia/Tokyo
restart: unless-stopped
deploy:
resources:
limits:
memory: 512m
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
あとは起動するだけ。
docker compose up -d
これで終わり。Portainer 派なら Stacks > Add stack に貼ってデプロイでも同じです。
4. Discordサーバーに招待
Developer Portal の OAuth2 から bot + applications.commands を選び、
権限は Connect Speak Send Messages Read Message History あたりを付与してURL生成→招待。
VCに入って /join で読み上げ開始です。
ハマったところ
スラッシュコマンドが反映されない
bot.tree.sync() を setup_hook で呼び忘れると、コマンドがDiscord側に登録されません。
グローバル同期は反映に最大1時間かかる仕様もあるので、開発中はギルド限定syncにすると秒で反映されます。
非rootユーザーで /ram_cache に書けない
Dockerfileで appuser を作って実行しているんですが、tmpfs マウントは初期所有者が root なので、コンテナ起動時に chown してから切り替える小技が必要でした。
CMD ["sh", "-c", "chown appuser:appuser /ram_cache /app/word_dict.json /app/settings.json 2>/dev/null; exec su -s /bin/sh appuser -c 'python bot.py'"]
動かしてみての所感
ラズパイ4 (4GB) に置いて、cAdvisor + VictoriaMetrics で計測しています。
直近6日連続稼働時点のスナップショットがこちら。
| 項目 | 値 |
|---|---|
| 再起動回数 | 0 |
| OOM Kill | なし |
| CPU 平均 / ピーク (15秒平均) | 1コアの 0.05% / 7.8% |
| SDカード書き込み (sda) | 316 KiB |
| Network RX / TX | 437 MB / 15.5 MB |
6日でSDカードへの書き込みが 316 KiB というのが、tmpfs 戦略の効果として一番効いています。
辞書/設定ファイルの更新時しかディスクに触らないので、このBOTがSDカードを縮める要因にはほぼなりません。
CPUは読み上げ生成時の瞬間ピークでも 1コアの 8% 未満。Pi 4 の4コアを掛け算するとほぼ無視できる負荷で、ほかのコンテナと同居させても十分余裕があります。
イメージを更新してコンテナを入れ替えた時も毎回 RestartCount=0 でクリーンに立ち上がってくれていて、ハマったことはありません。ffmpeg のゾンビプロセスやメモリリークも今のところ踏んでいません。
voice WebSocket は長時間つなぎっぱなしだと Discord 側から 1001 (going away) で切られることもありますが、discord.py が勝手に RESUME してくれるので、こちらで再接続まわりのコードを書かずに済みました。
複数人で使ったときの遅延も気になるほどではなく、人気BOTで起きていた「混雑時間帯に音が出ない」みたいな事象とも無縁です。
自分と友人数人しか使わないのでそもそもアクセスが集中する規模じゃない という当たり前の事実が一番効いてます。
おわりに
「人気BOTが不安定でストレス」というよくある不満を、自前でラズパイに置くだけで解決しました。
Docker と GHCR のおかげでデプロイも10分かからないくらいで終わります。
機能を絞って軽く作ってあるので、もうちょっと声に拘りたいとか、TTSエンジンを差し替えたいとか、フォークしてカスタマイズもしやすいはず。
Open JTalk以外の音声モデルは現状考えていません。VOICEVOXやAI系TTSはラズパイで動かすには少々重たいですし‥‥。
バグ報告・要望はGitHub Issue 等からお願いします。
ライセンス・クレジット
本BOTは下記の音声合成ソフトウェア・データを利用しています。
-
Open JTalk (Modified BSD License )
Copyright (c) 2008-2018 Nagoya Institute of Technology, Department of Computer Science
Developed by HTS Working Group (open-jtalk.sourceforge.net ) -
HTS Voice “Mei” (CC BY 3.0 )
Copyright (c) 2009-2015 Nagoya Institute of Technology, Department of Computer Science
Credits: Keiichi Tokuda / Akinobu Lee / Keiichiro Oura / MMDAgent Project Team
ライセンス全文はリポジトリの THIRD_PARTY_LICENSES
を参照してください。