discord.py + VOICEVOXで読み上げBOTを作成する

趣味で運営しているDiscordサーバー内で聞き専の方がいらっしゃったため、読み上げBOTを入れていたのですが、月の読み上げ上限数に達することが多かったため、「家にサーバーあるしせっかくなら読み上げBOTを自作するかぁ」ということで、読み上げBOTを作成しました。

調べたところ、VOICEVOXという音声合成エンジンが出てきたため、そちらを使用することにしました。

## 環境構築
今回は自宅のDebianサーバー上で動かしています。
VOICEVOXのインストールや設定については、以下の記事を参考にさせていただきました（圧倒的感謝）

https://qiita.com/kuelgu/items/81ad3c3faa4dee69d01b

BOT側にはVOICE PERMISSIONを与えるのをお忘れなく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351683/57e9ac39-bce9-4158-b3fc-b337935bde54.png)

あとは、ffmpegを使用しているので、apt等でインストールすればOKです。

## 使用技術
ざっと
- Discord.py
DiscordのBot作成用ライブラリ。Botの起動、メッセージの受信、ボイスチャンネルへの接続など。

- VOICEVOX
テキストを音声に変換するエンジン。VOICEVOX Coreを利用し、指定の話者での音声合成を実現。

- ONNX Runtime
高速な機械学習モデルの推論を実現するエンジン。VOICEVOXCoreでは、ONNX Runtimeを利用してモデルの推論を効率化し、音声合成処理を高速に行っている。(とのこと)

- Open JTalk
音声合成時に必要な辞書ファイル。

## 実装コード

```python
import discord
import tempfile
from pathlib import Path
from voicevox_core import VoicevoxCore, METAS
from dotenv import load_dotenv
import os

load_dotenv()  # 環境変数をロード

TOKEN = os.getenv('DISCORD_TOKEN')  # Discordトークンを取得
JTALK_PATH = os.getenv('JTALK_PATH')  # Open JTalkのパスを取得

client = discord.Client(intents=discord.Intents.all())
voice_client = None
text_channel = None
speaker_id = 24  # 1:ずんだもん,2:四国めたん,23:多分WhiteCULNormal,24:多分WhiteCULEnjoy

def save_tempfile(text: str, speaker: int):
    # 音声合成のためのテンポラリファイルを作成する処理
    core = VoicevoxCore(open_jtalk_dict_dir=Path(JTALK_PATH))
  
    if not core.is_model_loaded(speaker):
        core.load_model(speaker)  # モデルがロードされていなければロード
        wave_bytes = core.tts(text, speaker)  # テキストを音声合成

    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wf:
        wf.write(wave_bytes)  # 合成した音声データを書き込み
        wf.close()
        path = wf.name  # ファイルパスを取得

    return path

@client.event
async def on_ready():
    # Bot起動完了時の処理
    print("起動しました。")

@client.event
async def on_message(message):
    global voice_client
    global text_channel

    # Botのメッセージは無視する
    if message.author.bot:
        return

    if message.content == "join":
        # joinコマンドの場合の処理
        user = message.author

        # ユーザーがボイスチャンネルに接続していない場合の処理
        if not user.voice:
            await message.channel.send("ボイスチャンネルに接続していません。")
            return
        await message.channel.send("ボイスチャンネルに接続しました。")
        text_channel = message.channel  # テキストチャンネルを保存
        voice_channel = user.voice.channel  # ユーザーのボイスチャンネルを参照
        voice_client = await voice_channel.connect()  # ボイスチャンネルに接続
        # 接続確認のため音声を再生する
        path = save_tempfile("接続しました", speaker_id)
        voice_client.play(discord.FFmpegPCMAudio(path))
    elif message.content == "left":
        # leftコマンドの場合の処理
        if not voice_client:
            await message.channel.send("ボイスチャンネルに接続していません。")
            return
        await message.channel.send("切断しました。")
        await voice_client.disconnect()  # ボイスチャンネルから切断
        voice_client = None
        text_channel = None
    else:
        # その他のメッセージ処理（テキストチャンネルが一致している場合）
        if not text_channel == message.channel:
            return
        
        # URLで始まるメッセージは無視する
        if message.content.startswith("http") or message.content.startswith("https"):
            return
        
        # 40文字を超える場合は警告メッセージを送信して処理中断
        if len(message.content) > 40:
          await message.channel.send("40文字を超える文は対応していません")
          return
        
        # メッセージ内容を音声合成して再生
        path = save_tempfile(message.content, speaker_id)
        voice_client.play(discord.FFmpegPCMAudio(path))

@client.event
async def on_voice_state_update(member, before, after):
    global voice_client
    global text_channel
    # ボイスチャンネルにBot以外のメンバーが存在しなくなった場合の処理
    if voice_client and len([m for m in voice_client.channel.members if not m.bot]) == 0:
        await voice_client.disconnect()
        voice_client = None
        text_channel = None

client.run(TOKEN)  # Botクライアントの実行
```

## 主な機能の説明
- 音声合成処理
save_tempfile関数
テキストを受け取り、VOICEVOXを使用して音声合成を実施。
生成された音声データは一時ファイルとして保存され、後でDiscordの音声再生に使用されます。
- Discordとの連携
on_messageイベント
ユーザーから送信されたメッセージに対して、特定のコマンド（join、left）やテキストの読み上げ処理を実行します。

    - join: ユーザーがボイスチャンネルに接続している場合、そのチャンネルにBotが参加し、接続確認の音声を再生します。
    - left: ボイスチャンネルからBotが切断されます。
    - その他のメッセージはテキストとして読み上げ、音声を再生します。（ただしURLは除外し、40文字以内に制限）
- on_voice_state_updateイベント
Botが参加しているボイスチャンネルにBot以外のユーザーがいなくなった場合、自動で切断する処理を行っています。

## 使用感
今回サーバーにはGPUがなく、CPUモードで動かしているのですが、レスポンスはすぐに帰ってきました。
（ただ、文字数が長くなると時間が長くなるため、だいたい快適に使えたラインの40文字制限を追加しました）
サーバーのスペックにもよると思いますが、外部の読み上げボットとも大きな差はなく快適に使用できています。