0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

discordの読み上げbot作成 -part3 (話者変更)

Posted at

今回やること

今回は、前回までに作成した読み上げBotに「話者のカスタマイズ機能」を追加していきます!

  • ユーザーが 好きな話者・スタイルを自由に設定 できるようにする
  • 設定していないユーザーには 他の人とかぶらないランダム話者 を自動割り当て
  • 「VCに複数人いるときのボイス被り」を防ぎつつ、自然な読み上げ体験を目指す

前回までの記事

🔗 discordの読み上げbot作成 (Mac Intel上で作成)
🔗 discordの読み上げbot作成 -part2 (自動退室)

🗣 VOICEVOXの「話者」と「スタイル」とは?

VOICEVOXでは、

  • 話者(speaker):声の種類(四国めたん、ずんだもんなど)
  • スタイル(style):話し方のバリエーション(ノーマル、元気、ささやき など)

が組み合わさって1つの「音声」を構成しています。
このBotでは、ユーザーごとに speaker_uuidstyle_id を設定することで、読み上げボイスを個別に管理しています。

実装

グローバルな変数で話者を管理

  • 変数guild_speaker_settingsにサーバー(ギルド)、ユーザー別の設定を保持する
  • 関数register_speaker_settingを使うことで新しい設定を保存できるようにする
from typing import Dict, Optional, List, Tuple

# ギルドごとの話者IDとスタイルIDの辞書 (speaker_uuid, style_id)
# {guild_id: {user_id: (speaker_uuid, style_id), ...}, ...}
guild_speaker_settings: Dict[int, Dict[int, Tuple[str, int]]] = {}

def register_speaker_setting(guild_id: int, user_id: int, speaker_uuid: str, style_id: int):
    """
    ギルドごとの話者設定を登録する。
    必要に応じて辞書を初期化しながら登録を行う。
    
    Args:
        guild_id (int): ギルドID
        user_id (int): ユーザーID
        speaker_uuid (str): 話者のUUID
        style_id (int): スタイルID
    """
    if guild_id not in guild_speaker_settings:
        guild_speaker_settings[guild_id] = {}

    if user_id not in guild_speaker_settings[guild_id]:
        guild_speaker_settings[guild_id][user_id] = {}

    guild_speaker_settings[guild_id][user_id] = (speaker_uuid, style_id)

🛠 ユーザーの話者を設定する /voicechange コマンド

話者名とスタイルIDを選んで、Botに読み上げさせたい声を自由に設定できます。

bot.tree.command(name="voicechange", description="話者とスタイルを変更します")
@app_commands.describe(speaker="選択する話者", style="選択するスタイル(任意)")
async def voicechange(interaction: discord.Interaction, speaker: str, style: Optional[int] = None):
    """ ユーザーが話者とスタイルを選択できるコマンド """
    guild_id = interaction.guild.id
    user_id = interaction.user.id
    print(f"guild_id: {guild_id}, user_id: {user_id}, speaker:{speaker}, style_id: {style}")
    
    # 指定された話者の情報を取得
    speaker_info = get_speaker_info_by_uuid(speaker)
    if not speaker_info:
        await interaction.response.send_message("❌ 話者が見つかりません!", ephemeral=True)
        return

    speaker_uuid = speaker
    speaker_name = speaker_info["name"]
    styles = speaker_info["styles"]
    
    # スタイルの処理(指定がなければ最初のスタイルを使用)
    selected_style_id = None
    selected_style_name = None
    if style:
        for s in styles:
            if s["id"] == style:
                selected_style_id = style
                selected_style_name = s["name"]
                break
        if selected_style_id is None:
            print(f"selected_style_id: {selected_style_id},selected_style_name:{selected_style_name}")
            await interaction.response.send_message("❌ 指定されたスタイルが見つかりません!", ephemeral=True)
            return
    else:
        selected_style_id = styles[0]["id"]  # デフォルトのスタイル
        selected_style_name = styles[0]["name"]
    
    # 設定を保存
    register_speaker_setting(guild_id, user_id, speaker_uuid, selected_style_id)
    
    await interaction.response.send_message(f"✅ 話者を `{speaker_name}`(スタイル: `{selected_style_name}`)に変更しました!")

@voicechange.autocomplete("speaker")
async def speaker_autocomplete(interaction: discord.Interaction, current: str):
    """ 話者のオートコンプリート """
    speakers = get_speakers()
    return [app_commands.Choice(name=s["name"], value=s["speaker_uuid"]) for s in speakers if current in s["name"]][:25]

@voicechange.autocomplete("style")
async def style_autocomplete(interaction: discord.Interaction, current: str):
    """ スタイルのオートコンプリート """
    options = {opt["name"]: opt["value"] for opt in interaction.data.get("options", [])}
    speaker = options.get("speaker")

    if not speaker:
        return []  # speaker が未選択ならオートコンプリートを表示しない

    # 話者情報を取得
    speaker_info = get_speaker_info_by_uuid(speaker)
    if not speaker_info:
        return []

    # スタイルの候補を取得
    styles = speaker_info["styles"]
    return [app_commands.Choice(name=s["name"], value=s["id"]) for s in styles if current.lower() in s["name"].lower()][:25]

def get_speakers() -> List[Dict]:
    """VOICEVOXのspeakersエンドポイントから話者情報を取得する"""
    try:
        response = requests.get(f"{VOICEVOX_URL}/speakers")
        response.raise_for_status()  # エラーレスポンスの場合は例外を発生させる
        return response.json()
    except requests.exceptions.RequestException as e:
        print(f"❌ /speakers エンドポイントへのリクエストエラー: {e}")
        return []  # エラー時は空のリストを返す

    return None

読み上げ時に設定した話者を使用する

guild_speaker_settingsにユーザーの設定が存在するか確認し、存在する場合はその設定を使用します。
初めて発言するユーザーなど、話者が未設定の場合は get_random_speaker_and_styleランダムに話者を割り当て ます。


@bot.event
async def on_message(message):
    """ボットが接続しているVCのテキストチャンネルのメッセージのみを読み上げ"""
    if message.author == bot.user or message.author.bot:
        return  # ボット自身や他のBotのメッセージは無視

    # ボットがVCに接続していなければ無視
    if not message.guild or not message.guild.voice_client:
        return

    # 読み上げるメッセージ内容
    text = message.content

    # ボットが接続しているVCのテキストチャンネルか確認
    if message.guild.id not in vc_text_channels or message.channel.id != vc_text_channels[message.guild.id]:
        print(f"対象外のメッセージを無視: {text}")
        return  # VCのテキストチャンネル以外は無視

    guild_id = message.guild.id
    user_id = message.author.id
    voice_client = message.guild.voice_client
    current_voice_channel_members = voice_client.channel.members if voice_client else []


    # 話者とスタイルのIDを取得
    if guild_id in guild_speaker_settings and user_id in guild_speaker_settings[guild_id]:
        # ユーザーごとの設定が存在する場合
        speaker_uuid, style_id = guild_speaker_settings[guild_id][user_id]
        print(f"✅ ユーザー設定を使用: guild_id={guild_id}, user_id={user_id}, speaker_uuid={speaker_uuid}, style_id={style_id}")
    else:
        # ユーザーごとの設定が存在しない場合、ランダムに選択
        random_speaker_info = get_random_speaker_and_style(guild_id, current_voice_channel_members)
        if random_speaker_info:
            speaker_uuid, style_id = random_speaker_info
            print(f"✅ ランダムな話者を選択: guild_id={guild_id}, user_id={user_id}, speaker_uuid={speaker_uuid}, style_id={style_id}")
            register_speaker_setting(guild_id,user_id,speaker_uuid, style_id)
        else:
            # ランダム選択に失敗した場合(speakers APIがエラーなど)
            speaker_uuid = "1" # デフォルト話者ID
            style_id = 1
            print(f"❌ 話者の取得に失敗。デフォルトを使用: guild_id={guild_id}, user_id={user_id}, speaker_uuid={speaker_uuid}, style_id={style_id}")

    # VOICEVOXで音声合成
    query_payload = {"text": text, "speaker": style_id} # パラメータを修正
    try:
        query_response = requests.post(f"{VOICEVOX_URL}/audio_query", params=query_payload)
        query_response.raise_for_status()  # エラーレスポンスをチェック
        query_data = query_response.json()
    except requests.exceptions.RequestException as e:
        print(f"❌ audio_query エラー: {e}, text: {text}, speaker_uuid: {speaker_uuid}, style_id: {style_id}")
        await message.channel.send(f"❌ 音声合成に失敗しました。audio_query エラー: {e}")
        return

    try:
        synthesis_response = requests.post(f"{VOICEVOX_URL}/synthesis", json=query_data, params={"speaker": style_id})
        synthesis_response.raise_for_status()  # エラーレスポンスをチェック

        filename = "voice.wav"
        with open(filename, "wb") as f:
            f.write(synthesis_response.content)
            # VCで再生(WAVをそのまま)
        source = discord.FFmpegPCMAudio(filename)
        message.guild.voice_client.play(source)

    except requests.exceptions.RequestException as e:
        print(f"❌ synthesis エラー: {e}, text: {text}, speaker_uuid: {speaker_uuid}")
        await message.channel.send(f"❌ 音声合成に失敗しました。synthesis エラー: {e}")
        return
    except Exception as e:
        print(f"❌予期せぬエラー: {e}")
        await message.channel.send(f"❌ 予期せぬエラーが発生しました: {e}")
        return

    await bot.process_commands(message)  # 他のコマンドも処理

def get_random_speaker_and_style(guild_id: int, current_voice_channel_members: List[discord.Member]) -> Optional[Tuple[str, int]]:
    """
    VC内の他のメンバーと異なるランダムな話者とスタイルを取得する。

    Args:
        guild_id (int): ギルドID
        current_voice_channel_members (List[discord.Member]): 現在のVCのメンバーリスト

    Returns:
        Optional[Tuple[str, int]]: (speaker_uuid, style_id)のタプル。見つからない場合はNone。
    """
    speakers = get_speakers()
    if not speakers:
        return None

    # 現在VCにいるbot以外のユーザーのspeaker_uuidのセットを取得
    used_speaker_uuids = set()
    for member in current_voice_channel_members:
        if not member.bot:
            member_id = member.id
            if guild_id in guild_speaker_settings and member_id in guild_speaker_settings[guild_id]:
                used_speaker_uuids.add(guild_speaker_settings[guild_id][member_id][0])

    available_speakers = [speaker for speaker in speakers if speaker['speaker_uuid'] not in used_speaker_uuids]

    if not available_speakers:
        # 全てのspeakerが使用中の場合は、最初のspeakerの最初のstyleを返す
        if speakers:
            first_speaker = speakers[0]
            return first_speaker['speaker_uuid'], first_speaker['styles'][0]['id']
        else:
            return None  # スピーカーがいない場合はNoneを返す

    # ランダムに話者を選択
    selected_speaker = random.choice(available_speakers)
    selected_style_id = selected_speaker['styles'][0]['id']  # 最初のスタイルを選択
    return selected_speaker['speaker_uuid'], selected_style_id

コード全体

import os
import discord
import requests
import random
import asyncio
import uuid
from discord import app_commands
from discord.ext import commands, tasks
from typing import Dict, Optional, List, Tuple

TOKEN = "あなたのボットトークン"
VOICEVOX_URL = "http://localhost:50021"

intents = discord.Intents.default()
intents.message_content = True
intents.voice_states = True  # VCの状態を取得するために必要
bot = commands.Bot(command_prefix="/", intents=intents)

# ボットが接続しているVCのテキストチャンネルを記録
vc_text_channels = {}

# ギルドごとの話者IDとスタイルIDの辞書 (speaker_uuid, style_id)
# {guild_id: {user_id: (speaker_uuid, style_id), ...}, ...}
guild_speaker_settings: Dict[int, Dict[int, Tuple[str, int]]] = {}

def register_speaker_setting(guild_id: int, user_id: int, speaker_uuid: str, style_id: int):
    """
    ギルドごとの話者設定を登録する。
    必要に応じて辞書を初期化しながら登録を行う。
    
    Args:
        guild_id (int): ギルドID
        user_id (int): ユーザーID
        speaker_uuid (str): 話者のUUID
        style_id (int): スタイルID
    """
    if guild_id not in guild_speaker_settings:
        guild_speaker_settings[guild_id] = {}

    guild_speaker_settings[guild_id][user_id] = (speaker_uuid, style_id)


def get_speakers() -> List[Dict]:
    """VOICEVOXのspeakersエンドポイントから話者情報を取得する"""
    try:
        response = requests.get(f"{VOICEVOX_URL}/speakers")
        response.raise_for_status()  # エラーレスポンスの場合は例外を発生させる
        return response.json()
    except requests.exceptions.RequestException as e:
        print(f"❌ /speakers エンドポイントへのリクエストエラー: {e}")
        return []  # エラー時は空のリストを返す

def get_speaker_names_and_ids() -> Dict[str, str]:
    """
    VOICEVOXのspeakersエンドポイントから話者名とIDの対応を取得する。
    speaker_nameをkey, speaker_uuidをvalueとする辞書を返す。
    """
    speakers_data = get_speakers()
    if not speakers_data:
        return {}
    # speaker_nameをkey, speaker_uuidをvalueとする辞書に変換
    return {speaker['name']: speaker['speaker_uuid'] for speaker in speakers_data}

def get_speaker_info_by_uuid(speaker_uuid: str) -> Optional[Dict]:
    """
    指定された話者名の話者情報を取得する。
    """
    speakers = get_speakers()
    if not speakers:
        return None
    for speaker in speakers:
        if speaker['speaker_uuid'] == speaker_uuid:
            return speaker
    return None

def get_random_speaker_and_style(guild_id: int, current_voice_channel_members: List[discord.Member]) -> Optional[Tuple[str, int]]:
    """
    VC内の他のメンバーと異なるランダムな話者とスタイルを取得する。

    Args:
        guild_id (int): ギルドID
        current_voice_channel_members (List[discord.Member]): 現在のVCのメンバーリスト

    Returns:
        Optional[Tuple[str, int]]: (speaker_uuid, style_id)のタプル。見つからない場合はNone。
    """
    speakers = get_speakers()
    if not speakers:
        return None

    # 現在VCにいるbot以外のユーザーのspeaker_uuidのセットを取得
    used_speaker_uuids = set()
    for member in current_voice_channel_members:
        if not member.bot:
            member_id = member.id
            if guild_id in guild_speaker_settings and member_id in guild_speaker_settings[guild_id]:
                used_speaker_uuids.add(guild_speaker_settings[guild_id][member_id][0])

    available_speakers = [speaker for speaker in speakers if speaker['speaker_uuid'] not in used_speaker_uuids]

    if not available_speakers:
        # 全てのspeakerが使用中の場合は、最初のspeakerの最初のstyleを返す
        if speakers:
            first_speaker = speakers[0]
            return first_speaker['speaker_uuid'], first_speaker['styles'][0]['id']
        else:
            return None  # スピーカーがいない場合はNoneを返す

    # ランダムに話者を選択
    selected_speaker = random.choice(available_speakers)
    selected_style_id = selected_speaker['styles'][0]['id']  # 最初のスタイルを選択
    return selected_speaker['speaker_uuid'], selected_style_id

@bot.event
async def on_ready():
    print(f"{bot.user} が起動しました!")
    try:
        synced = await bot.tree.sync()
        print(f"✅ スラッシュコマンド {len(synced)} 個を同期しました!")
    except Exception as e:
        print(f"❌ スラッシュコマンドの同期に失敗: {e}")

@bot.tree.command(name="join", description="ボイスチャットに参加します")
async def join(interaction: discord.Interaction):
    """ボットをVCに参加させる"""
    if interaction.user.voice:
        channel = interaction.user.voice.channel
        vc = await channel.connect(reconnect=True) 
        if not vc:
            await interaction.response.send_message("❌ VCへの接続に失敗しました。", ephemeral=True)
            return
        
        vc_text_channels[interaction.guild.id] = interaction.channel.id  # ボットが接続したVCのテキストチャンネルを記録
        await interaction.response.send_message("✅ ボイスチャンネルに参加しました!")

        # 🔴 チェックループが動作していない場合は開始
        if not check_empty_vc.is_running():
            check_empty_vc.start(interaction.guild)
    else:
        await interaction.response.send_message("❌ 先にボイスチャンネルに参加してください!", ephemeral=True)

@bot.tree.command(name="disconnect", description="ボイスチャットから退出")
async def disconnect(interaction: discord.Interaction):
    """ボットをVCから切断"""
    if interaction.guild.voice_client:
        await interaction.guild.voice_client.disconnect()
        vc_text_channels.pop(interaction.guild.id, None)  # 記録していたテキストチャンネルIDを削除
        check_empty_vc.cancel()  # 自動切断のチェックを停止

        vc_text_channels.pop(interaction.guild.id, None)
        await interaction.response.send_message("✅ ボイスチャンネルから切断しました!")
    else:
        await interaction.response.send_message("❌ ボットはVCにいません!", ephemeral=True)

@tasks.loop(seconds=2)
async def check_empty_vc(guild: discord.Guild):
    """VCのメンバーが0人になったら自動で切断"""
    if guild.voice_client:
        vc = guild.voice_client.channel
        members = [member for member in vc.members if not member.bot]  # 人間のみをカウント
        if len(members) == 0:
            print(f"⚠️ {vc.name} に誰もいないため、自動切断します")
            vc_text_channels.pop(guild.id, None)
            await guild.voice_client.disconnect()
            check_empty_vc.cancel()  # ループを停止


@bot.event
async def on_message(message):
    """ボットが接続しているVCのテキストチャンネルのメッセージのみを読み上げ"""
    if message.author == bot.user or message.author.bot:
        return  # ボット自身や他のBotのメッセージは無視

    # ボットがVCに接続していなければ無視
    if not message.guild or not message.guild.voice_client:
        return

    # 読み上げるメッセージ内容
    text = message.content

    # ボットが接続しているVCのテキストチャンネルか確認
    if message.guild.id not in vc_text_channels or message.channel.id != vc_text_channels[message.guild.id]:
        print(f"対象外のメッセージを無視: {text}")
        return  # VCのテキストチャンネル以外は無視

    guild_id = message.guild.id
    user_id = message.author.id
    voice_client = message.guild.voice_client
    current_voice_channel_members = voice_client.channel.members if voice_client else []


    # 話者とスタイルのIDを取得
    if guild_id in guild_speaker_settings and user_id in guild_speaker_settings[guild_id]:
        # ユーザーごとの設定が存在する場合
        speaker_uuid, style_id = guild_speaker_settings[guild_id][user_id]
        print(f"✅ ユーザー設定を使用: guild_id={guild_id}, user_id={user_id}, speaker_uuid={speaker_uuid}, style_id={style_id}")
    else:
        # ユーザーごとの設定が存在しない場合、ランダムに選択
        random_speaker_info = get_random_speaker_and_style(guild_id, current_voice_channel_members)
        if random_speaker_info:
            speaker_uuid, style_id = random_speaker_info
            print(f"✅ ランダムな話者を選択: guild_id={guild_id}, user_id={user_id}, speaker_uuid={speaker_uuid}, style_id={style_id}")
            register_speaker_setting(guild_id,user_id,speaker_uuid, style_id)
        else:
            # ランダム選択に失敗した場合(speakers APIがエラーなど)
            speaker_uuid = "1" # デフォルト話者ID
            style_id = 1
            print(f"❌ 話者の取得に失敗。デフォルトを使用: guild_id={guild_id}, user_id={user_id}, speaker_uuid={speaker_uuid}, style_id={style_id}")

    # VOICEVOXで音声合成
    query_payload = {"text": text, "speaker": style_id} # パラメータを修正
    try:
        query_response = requests.post(f"{VOICEVOX_URL}/audio_query", params=query_payload)
        query_response.raise_for_status()  # エラーレスポンスをチェック
        query_data = query_response.json()
    except requests.exceptions.RequestException as e:
        print(f"❌ audio_query エラー: {e}, text: {text}, speaker_uuid: {speaker_uuid}, style_id: {style_id}")
        await message.channel.send(f"❌ 音声合成に失敗しました。audio_query エラー: {e}")
        return

    try:
        synthesis_response = requests.post(f"{VOICEVOX_URL}/synthesis", json=query_data, params={"speaker": style_id})
        synthesis_response.raise_for_status()  # エラーレスポンスをチェック

        filename = "voice.wav"
        with open(filename, "wb") as f:
            f.write(synthesis_response.content)
            # VCで再生(WAVをそのまま)
        source = discord.FFmpegPCMAudio(filename)
        message.guild.voice_client.play(source)

    except requests.exceptions.RequestException as e:
        print(f"❌ synthesis エラー: {e}, text: {text}, speaker_uuid: {speaker_uuid}")
        await message.channel.send(f"❌ 音声合成に失敗しました。synthesis エラー: {e}")
        return
    except Exception as e:
        print(f"❌予期せぬエラー: {e}")
        await message.channel.send(f"❌ 予期せぬエラーが発生しました: {e}")
        return

    await bot.process_commands(message)  # 他のコマンドも処理

@bot.tree.command(name="voicechange", description="話者とスタイルを変更します")
@app_commands.describe(speaker="選択する話者", style="選択するスタイル(任意)")
async def voicechange(interaction: discord.Interaction, speaker: str, style: Optional[int] = None):
    """ ユーザーが話者とスタイルを選択できるコマンド """
    guild_id = interaction.guild.id
    user_id = interaction.user.id
    print(f"guild_id: {guild_id}, user_id: {user_id}, speaker:{speaker}, style_id: {style}")
    
    # 指定された話者の情報を取得
    speaker_info = get_speaker_info_by_uuid(speaker)
    if not speaker_info:
        await interaction.response.send_message("❌ 話者が見つかりません!", ephemeral=True)
        return

    speaker_uuid = speaker
    speaker_name = speaker_info["name"]
    styles = speaker_info["styles"]
    
    # スタイルの処理(指定がなければ最初のスタイルを使用)
    selected_style_id = None
    selected_style_name = None
    if style:
        for s in styles:
            if s["id"] == style:
                selected_style_id = style
                selected_style_name = s["name"]
                break
        if selected_style_id is None:
            print(f"selected_style_id: {selected_style_id},selected_style_name:{selected_style_name}")
            await interaction.response.send_message("❌ 指定されたスタイルが見つかりません!", ephemeral=True)
            return
    else:
        selected_style_id = styles[0]["id"]  # デフォルトのスタイル
        selected_style_name = styles[0]["name"]
    
    # 設定を保存
    register_speaker_setting(guild_id, user_id, speaker_uuid, selected_style_id)
    
    await interaction.response.send_message(f"✅ 話者を `{speaker_name}`(スタイル: `{selected_style_name}`)に変更しました!")

@voicechange.autocomplete("speaker")
async def speaker_autocomplete(interaction: discord.Interaction, current: str):
    """ 話者のオートコンプリート """
    speakers = get_speakers()
    return [app_commands.Choice(name=s["name"], value=s["speaker_uuid"]) for s in speakers if current in s["name"]][:25]

@voicechange.autocomplete("style")
async def style_autocomplete(interaction: discord.Interaction, current: str):
    """ スタイルのオートコンプリート """
    options = {opt["name"]: opt["value"] for opt in interaction.data.get("options", [])}
    speaker = options.get("speaker")

    if not speaker:
        return []  # speaker が未選択ならオートコンプリートを表示しない

    # 話者情報を取得
    speaker_info = get_speaker_info_by_uuid(speaker)
    if not speaker_info:
        return []

    # スタイルの候補を取得
    styles = speaker_info["styles"]
    return [app_commands.Choice(name=s["name"], value=s["id"]) for s in styles if current.lower() in s["name"].lower()][:25]



bot.run(TOKEN)

📝 今後の展望(次回予告)

  • 音声ファイルをキューで管理し、複数の発言があった場合も対応可能にする
  • 再生するファイル名が固定であるため、ユニークな名前にし重複を避ける
  • ファイル再生後に削除する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?