今回やること
今回は、前回までに作成した読み上げBotに「話者のカスタマイズ機能」を追加していきます!
- ユーザーが 好きな話者・スタイルを自由に設定 できるようにする
- 設定していないユーザーには 他の人とかぶらないランダム話者 を自動割り当て
- 「VCに複数人いるときのボイス被り」を防ぎつつ、自然な読み上げ体験を目指す
前回までの記事
🔗 discordの読み上げbot作成 (Mac Intel上で作成)
🔗 discordの読み上げbot作成 -part2 (自動退室)
🗣 VOICEVOXの「話者」と「スタイル」とは?
VOICEVOXでは、
- 話者(speaker):声の種類(四国めたん、ずんだもんなど)
- スタイル(style):話し方のバリエーション(ノーマル、元気、ささやき など)
が組み合わさって1つの「音声」を構成しています。
このBotでは、ユーザーごとに speaker_uuid
と style_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)
📝 今後の展望(次回予告)
- 音声ファイルをキューで管理し、複数の発言があった場合も対応可能にする
- 再生するファイル名が固定であるため、ユニークな名前にし重複を避ける
- ファイル再生後に削除する