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

## 今回やること
今回は、前回までに作成した読み上げBotに「話者のカスタマイズ機能」を追加していきます！
- ユーザーが **好きな話者・スタイルを自由に設定** できるようにする
- 設定していないユーザーには **他の人とかぶらないランダム話者** を自動割り当て
- 「VCに複数人いるときのボイス被り」を防ぎつつ、自然な読み上げ体験を目指す

## 前回までの記事

> 🔗 [discordの読み上げbot作成 (Mac Intel上で作成)](https://qiita.com/Mimmy/items/3a404d995a6f1d849188)  
> 🔗 [discordの読み上げbot作成 -part2 (自動退室)](https://qiita.com/Mimmy/items/b35a318ea2ee749f4d31)

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

VOICEVOXでは、
- **話者（speaker）**：声の種類（四国めたん、ずんだもんなど）
- **スタイル（style）**：話し方のバリエーション（ノーマル、元気、ささやき など）

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

## 実装
### グローバルな変数で話者を管理
- 変数``guild_speaker_settings``にサーバー（ギルド）、ユーザー別の設定を保持する
- 関数``register_speaker_setting``を使うことで新しい設定を保存できるようにする

```python
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に読み上げさせたい声を自由に設定できます。

```python
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` で **ランダムに話者を割り当て** ます。

```python

@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

```
## コード全体
```python
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)

```

## 📝 今後の展望（次回予告）
- 音声ファイルをキューで管理し、複数の発言があった場合も対応可能にする
- 再生するファイル名が固定であるため、ユニークな名前にし重複を避ける
- ファイル再生後に削除する