【Python】VOICE BOXを用いたDiscord読み上げBOTの作成

導入

Windows 11のVisual Studio Code環境で動作するオフライン環境のVOICE BOX APIを利用したDiscord専用のテキスト読み上げBOTを開発しました。ユーザーが特定のテキストチャンネルに投稿したメッセージを解析し、オフラインのVOICEVOX API を利用して リアルタイム音声読み上げを行う BOT です。

このプロジェクトでは以下の点を重視しています:

  • ✔️ VOICEVOX API を用いた高品質 TTS 生成
  • ✔️ Discord API(discord.py)による音声チャンネル制御
  • ✔️ 自動接続・退出・再起動まで含めた安定動作
  • ✔️ メッセージの保存・ログ化
  • ✔️ キャッシュ機能による再読み上げ最適化

内容

音声読み上げロジック

本 BOT の音声機能は、VOICEVOX API を中心に構築しています。

  • メッセージ内容の受信
  • URL 判定(URL は「URL」と要約読み上げ)
  • 長文のときは先頭 30 文字のみを読み上げ
  • VOICEVOX の audio_query → synthesis による音声生成
  • WAV キャッシュ採用(同じテキストは再生成しない)
  • 再生後にキャッシュ削除し、ストレージを適切に管理

Discord 連携

  • 指定ボイスチャンネルへの自動参加
  • 読み上げ終了後は再生キューを監視
  • チャンネルが無人になった場合は BOT を自動再起動
  • 接続トラブル時の例外処理・再接続を実装

データ保存

  • 特定テキストチャンネルのメッセージを JSON 保存
  • timestamp / author / content を記録
  • セキュリティ領域はローカルフォルダへ安全に保管

実際に動作している環境

  • プラットフォーム:Python(discord.py)
  • TTS:VOICEVOX(localhost API)
  • 読み上げ API:aiohttp による非同期通信
  • 実行サーバー:Windows / Linux どちらでも対応
  • ※ 現在はポートフォリオ用に公開を停止

Pythonコード例

以下は実際の開発で使用したコードの一部を、
安全化したうえでポートフォリオ向けに整形したものです。

import discord
from discord.ext import commands
import aiohttp
import asyncio
import os
import json
import datetime
import re
import sys

CONFIG_PATH = "config.json"
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
    config = json.load(f)

DICTIONARY_PATH = "data/dictionary.json"
TTS_CACHE_DIR = "data/tts_cache"
SECURITY_MESSAGE_FILE = "data/messages.json"

os.makedirs("data", exist_ok=True)
os.makedirs(TTS_CACHE_DIR, exist_ok=True)

if not os.path.exists(DICTIONARY_PATH):
    with open(DICTIONARY_PATH, "w", encoding="utf-8") as f:
        json.dump({}, f, ensure_ascii=False, indent=4)

if not os.path.exists(SECURITY_MESSAGE_FILE):
    with open(SECURITY_MESSAGE_FILE, "w", encoding="utf-8") as f:
        json.dump([], f, ensure_ascii=False, indent=4)

TOKEN = config["discord_token"]
VOICE_CHANNEL_ID = config["voice_channel_id"]
TEXT_CHANNEL_ID = config["text_channel_id"]
VOICEVOX_API_BASE_URL = config.get("voicevox_url", "http://localhost:50021")
SPEAKER_ID = config.get("speaker_id", 1)
FFMPEG_PATH = config.get("ffmpeg_path", "ffmpeg")

intents = discord.Intents.default()
intents.messages = True
intents.guilds = True
intents.voice_states = True
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)

def load_dictionary():
    with open(DICTIONARY_PATH, "r", encoding="utf-8") as f:
        return json.load(f)

def save_dictionary(data):
    with open(DICTIONARY_PATH, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=4)

@bot.event
async def on_ready():
    print(f"Logged in as {bot.user}")

@bot.event
async def on_voice_state_update(member, before, after):
    if member.bot:
        return

    channel = bot.get_channel(VOICE_CHANNEL_ID)

    if before.channel != after.channel:
        if after.channel == channel:
            await announce_voice(f"{member.display_name} joined.")
        elif before.channel == channel:
            non_bot_members = [m for m in before.channel.members if not m.bot]
            if non_bot_members:
                await announce_voice(f"{member.display_name} left.")
            else:
                await asyncio.sleep(3)
                for vc in bot.voice_clients:
                    if vc.channel == channel:
                        await vc.disconnect()
                        await asyncio.sleep(1)
                        os.execv(sys.executable, ["python"] + sys.argv)

@bot.event
async def on_message(message):
    if message.author.bot:
        return

    if message.channel.id == TEXT_CHANNEL_ID:
        with open(SECURITY_MESSAGE_FILE, "r", encoding="utf-8") as f:
            logs = json.load(f)

        logs.append({
            "timestamp": datetime.datetime.now().isoformat(),
            "author": message.author.name,
            "content": message.content
        })

        with open(SECURITY_MESSAGE_FILE, "w", encoding="utf-8") as f:
            json.dump(logs, f, ensure_ascii=False, indent=4)

        if re.search(r'https?://\S+', message.content):
            await read_text("URL")
        elif len(message.content) >= 60:
            await read_text(message.content[:30] + "…")
        else:
            await read_text(message.content)

    await bot.process_commands(message)

async def get_tts_audio(text):
    dictionary = load_dictionary()
    for key, value in dictionary.items():
        text = text.replace(key, value)

    filename = os.path.join(TTS_CACHE_DIR, f"{hash(text)}.wav")
    if os.path.exists(filename):
        return filename

    try:
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{VOICEVOX_API_BASE_URL}/audio_query",
                params={"text": text, "speaker": SPEAKER_ID}
            ) as query_resp:

                if query_resp.status == 200:
                    audio_query = await query_resp.json()
                    async with session.post(
                        f"{VOICEVOX_API_BASE_URL}/synthesis",
                        params={"speaker": SPEAKER_ID},
                        json=audio_query
                    ) as synthesis_resp:

                        if synthesis_resp.status == 200:
                            with open(filename, "wb") as f:
                                f.write(await synthesis_resp.read())
                            return filename
    except Exception as e:
        print(f"TTS error: {e}")

    return None

async def read_text(text):
    channel = bot.get_channel(VOICE_CHANNEL_ID)
    if not channel:
        return

    if bot.voice_clients:
        vc = bot.voice_clients[0]
    else:
        vc = await channel.connect()

    audio_file = await get_tts_audio(text)
    if not audio_file:
        return

    try:
        vc.play(discord.FFmpegPCMAudio(audio_file, executable=FFMPEG_PATH))
        while vc.is_playing():
            await asyncio.sleep(0.5)
        await asyncio.sleep(1)
        if os.path.exists(audio_file):
            os.remove(audio_file)
    except Exception as e:
        print(f"Playback error: {e}")

async def announce_voice(text):
    await read_text(text)

@bot.command(name="add_word")
async def add_word(ctx, key: str, value: str):
    data = load_dictionary()
    data[key] = value
    save_dictionary(data)
    await ctx.send(f"Added: {key} → {value}")

@bot.command(name="remove_word")
async def remove_word(ctx, key: str):
    data = load_dictionary()
    if key in data:
        del data[key]
        save_dictionary(data)
        await ctx.send(f"Removed: {key}")
    else:
        await ctx.send("Not found.")

@bot.command(name="show_dictionary")
async def show_dictionary(ctx):
    data = load_dictionary()
    if data:
        content = "\n".join([f"{k}: {v}" for k, v in data.items()])
        await ctx.send(content)
    else:
        await ctx.send("Dictionary is empty.")

bot.run(TOKEN)

技術構成

技術内容
PythonBOT 本体・非同期処理
discord.pyDiscord API 操作
VOICEVOX API音声合成
aiohttp非同期 HTTP 通信
FFmpeg音声ファイルの再生処理
JSON設定ファイル・ログ保存
Session(簡易)Bot の再接続制御

実際のコード公開について

この BOT はポートフォリオ向けに安全化し、以下の項目は非公開としています。

  • Discord Token / Bot Key
  • 実際のフォルダ構成・ユーザー名
  • 運用環境のログ
  • セキュリティ関連の内部処理
  • 機密性の高いファイルパス

実際のシステム導入・追加機能開発が必要な場合は、個別に相談可能です。


お問い合わせ

本 BOT の開発や、「Discord × AI × 音声合成」を利用したカスタムシステムの制作について何かご質問がございました、お気軽にお問い合わせください。

Back to top